mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 16:49:47 +03:00
Compare commits
33 Commits
v21.12.4
...
prosemirro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d34f837e19 | ||
|
|
264966de02 | ||
|
|
8b4f112462 | ||
|
|
20f37292a1 | ||
|
|
b1f5495a7f | ||
|
|
bb12541179 | ||
|
|
e3ead1c115 | ||
|
|
9b4ea368dc | ||
|
|
4b08eef12c | ||
|
|
b2283106fc | ||
|
|
7125530e55 | ||
|
|
7622106665 | ||
|
|
89194a3f85 | ||
|
|
7703face52 | ||
|
|
c013d7e549 | ||
|
|
07c8876e22 | ||
|
|
0dc64d22ef | ||
|
|
013943dcc5 | ||
|
|
dc1c9807ef | ||
|
|
56d7864bdf | ||
|
|
1018b5627e | ||
|
|
717557df89 | ||
|
|
6744ab2ff9 | ||
|
|
4e5153d372 | ||
|
|
34db138a64 | ||
|
|
c3595b1807 | ||
|
|
a8f48185b5 | ||
|
|
9d7174557e | ||
|
|
47c3d4fc0f | ||
|
|
81dfe9c345 | ||
|
|
0fb8ba00a5 | ||
|
|
aa9fe9ca82 | ||
|
|
27f9e8e4bd |
@@ -297,11 +297,6 @@ RECYCLE_BIN_LIFETIME=30
|
||||
# Maximum file size, in megabytes, that can be uploaded to the system.
|
||||
FILE_UPLOAD_SIZE_LIMIT=50
|
||||
|
||||
# Export Page Size
|
||||
# Primarily used to determine page size of PDF exports.
|
||||
# Can be 'a4' or 'letter'.
|
||||
EXPORT_PAGE_SIZE=a4
|
||||
|
||||
# Allow <script> tags in page content
|
||||
# Note, if set to 'true' the page editor may still escape scripts.
|
||||
ALLOW_CONTENT_SCRIPTS=false
|
||||
|
||||
5
.github/translators.txt
vendored
5
.github/translators.txt
vendored
@@ -210,8 +210,3 @@ Tomáš Batelka (Vofy) :: Czech
|
||||
Mundo Racional (ismael.mesquita) :: Portuguese, Brazilian
|
||||
Zarik (3apuk) :: Russian
|
||||
Ali Shaatani (a.shaatani) :: Arabic
|
||||
ChacMaster :: Portuguese, Brazilian
|
||||
Saeed (saeed205) :: Persian
|
||||
Julesdevops :: French
|
||||
peter cerny (posli.to.semka) :: Slovak
|
||||
Pavel Karlin (pavelkarlin) :: Russian
|
||||
|
||||
4
.github/workflows/phpstan.yml
vendored
4
.github/workflows/phpstan.yml
vendored
@@ -3,10 +3,10 @@ name: phpstan
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
4
.github/workflows/phpunit.yml
vendored
4
.github/workflows/phpunit.yml
vendored
@@ -3,10 +3,10 @@ name: phpunit
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
4
.github/workflows/test-migrations.yml
vendored
4
.github/workflows/test-migrations.yml
vendored
@@ -3,10 +3,10 @@ name: test-migrations
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,10 +5,10 @@ Homestead.yaml
|
||||
.idea
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/public/dist/*.map
|
||||
/public/dist
|
||||
/public/plugins
|
||||
/public/css/*.map
|
||||
/public/js/*.map
|
||||
/public/css
|
||||
/public/js
|
||||
/public/bower
|
||||
/public/build/
|
||||
/storage/images
|
||||
|
||||
49
TODO
Normal file
49
TODO
Normal file
@@ -0,0 +1,49 @@
|
||||
### Next
|
||||
|
||||
- Table cell height resize & cell width resize via width style
|
||||
- Column resize source: https://github.com/ProseMirror/prosemirror-tables/blob/master/src/columnresizing.js
|
||||
- Have updated column resizing to set cell widths
|
||||
- Now need to handle table overall size on change, then heights.
|
||||
|
||||
- Details/Summary
|
||||
- Need view to control summary editability, make readonly but editable via popover.
|
||||
- Need some default styles to visualise details boundary.
|
||||
- Markdown parser needs to be updated to handle separate open/close tags for blocks.
|
||||
|
||||
### In-Progress
|
||||
|
||||
- Tables
|
||||
- Details/Summary
|
||||
|
||||
### Features
|
||||
|
||||
- Images
|
||||
- Drawings
|
||||
- LTR/RTL control
|
||||
- Fullscreen
|
||||
- Paste Image Uploading
|
||||
- Drag + Drop Image Uploading
|
||||
- Checkbox/TODO list items
|
||||
- Code blocks
|
||||
- Indents
|
||||
- Attachment integration (Drag & drop)
|
||||
- Template system integration.
|
||||
|
||||
### Improvements
|
||||
|
||||
- List type changing.
|
||||
- Color picker options should have "clear" option.
|
||||
- Color picker buttons should be split, with button to re-apply last selected color.
|
||||
- Color picker options should change color if different instead of remove.
|
||||
- Clear formatting, If no selection range, clear the formatting of parent block.
|
||||
- If no marks, clear the block type if text type?
|
||||
- Remove links button? (Action already in place if link href is empty).
|
||||
- Links - Validate URL.
|
||||
- Links - Integrate entity picker.
|
||||
- iFrame - Parse iframe HTML & auto-convert youtube/vimeo urls to embeds.
|
||||
|
||||
### Notes
|
||||
|
||||
- Use NodeViews for embedded content (Code, Drawings) where control is needed.
|
||||
- Probably still easiest to have seperate (codemirror) MD editor. Can alter display output via NodeViews to make MD like
|
||||
but its tricky since editing the markdown content would change the block definition/type while editing.
|
||||
@@ -60,11 +60,8 @@ class OidcJwtSigningKey
|
||||
*/
|
||||
protected function loadFromJwkArray(array $jwk)
|
||||
{
|
||||
// 'alg' is optional for a JWK, but we will still attempt to validate if
|
||||
// it exists otherwise presume it will be compatible.
|
||||
$alg = $jwk['alg'] ?? null;
|
||||
if ($jwk['kty'] !== 'RSA' || !(is_null($alg) || $alg === 'RS256')) {
|
||||
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
|
||||
if ($jwk['alg'] !== 'RS256') {
|
||||
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}");
|
||||
}
|
||||
|
||||
if (empty($jwk['use'])) {
|
||||
|
||||
@@ -164,9 +164,7 @@ class OidcProviderSettings
|
||||
protected function filterKeys(array $keys): array
|
||||
{
|
||||
return array_filter($keys, function (array $key) {
|
||||
$alg = $key['alg'] ?? null;
|
||||
|
||||
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
|
||||
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
*/
|
||||
public function attachDefaultRole(): void
|
||||
{
|
||||
$roleId = intval(setting('registration-role'));
|
||||
$roleId = setting('registration-role');
|
||||
if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
|
||||
$this->roles()->attach($roleId);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
* Configuration should be altered via the `.env` file or environment variables.
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
$dompdfPaperSizeMap = [
|
||||
'a4' => 'a4',
|
||||
'letter' => 'letter',
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
@@ -154,7 +150,7 @@ return [
|
||||
*
|
||||
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
|
||||
*/
|
||||
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
|
||||
'default_paper_size' => 'a4',
|
||||
|
||||
/**
|
||||
* The default font family.
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
* Configuration should be altered via the `.env` file or environment variables.
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
$snappyPaperSizeMap = [
|
||||
'a4' => 'A4',
|
||||
'letter' => 'Letter',
|
||||
];
|
||||
|
||||
return [
|
||||
'pdf' => [
|
||||
@@ -18,8 +14,7 @@ return [
|
||||
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
|
||||
'timeout' => false,
|
||||
'options' => [
|
||||
'outline' => true,
|
||||
'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
|
||||
'outline' => true,
|
||||
],
|
||||
'env' => [],
|
||||
],
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
use Symfony\Component\Console\Command\Command as SymfonyCommand;
|
||||
@@ -21,8 +19,7 @@ class CreateAdmin extends Command
|
||||
protected $signature = 'bookstack:create-admin
|
||||
{--email= : The email address for the new admin user}
|
||||
{--name= : The name of the new admin user}
|
||||
{--password= : The password to assign to the new admin user}
|
||||
{--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}';
|
||||
{--password= : The password to assign to the new admin user}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -45,35 +42,28 @@ class CreateAdmin extends Command
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
* @throws \BookStack\Exceptions\NotFoundException
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$details = $this->snakeCaseOptions();
|
||||
$details = $this->options();
|
||||
|
||||
if (empty($details['email'])) {
|
||||
$details['email'] = $this->ask('Please specify an email address for the new admin user');
|
||||
}
|
||||
|
||||
if (empty($details['name'])) {
|
||||
$details['name'] = $this->ask('Please specify a name for the new admin user');
|
||||
}
|
||||
|
||||
if (empty($details['password'])) {
|
||||
if (empty($details['external_auth_id'])) {
|
||||
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
|
||||
} else {
|
||||
$details['password'] = Str::random(32);
|
||||
}
|
||||
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
|
||||
}
|
||||
|
||||
$validator = Validator::make($details, [
|
||||
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
|
||||
'name' => ['required', 'min:2'],
|
||||
'password' => ['required_without:external_auth_id', Password::default()],
|
||||
'external_auth_id' => ['required_without:password'],
|
||||
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
|
||||
'name' => ['required', 'min:2'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -94,14 +84,4 @@ class CreateAdmin extends Command
|
||||
|
||||
return SymfonyCommand::SUCCESS;
|
||||
}
|
||||
|
||||
protected function snakeCaseOptions(): array
|
||||
{
|
||||
$returnOpts = [];
|
||||
foreach ($this->options() as $key => $value) {
|
||||
$returnOpts[str_replace('-', '_', $key)] = $value;
|
||||
}
|
||||
|
||||
return $returnOpts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class Deletion extends Model implements Loggable
|
||||
/**
|
||||
* Get a URL for this specific deletion.
|
||||
*/
|
||||
public function getUrl(string $path = 'restore'): string
|
||||
public function getUrl($path): string
|
||||
{
|
||||
return url("/settings/recycle-bin/{$this->id}/" . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property string $slug
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon $deleted_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property bool $restricted
|
||||
|
||||
@@ -46,10 +46,19 @@ class PageRevision extends Model
|
||||
|
||||
/**
|
||||
* Get the url for this revision.
|
||||
*
|
||||
* @param null|string $path
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUrl(string $path = ''): string
|
||||
public function getUrl($path = null)
|
||||
{
|
||||
return $this->page->getUrl('/revisions/' . $this->id . '/' . ltrim($path, '/'));
|
||||
$url = $this->page->getUrl() . '/revisions/' . $this->id;
|
||||
if ($path) {
|
||||
return $url . '/' . trim($path, '/');
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -92,7 +92,6 @@ class ExportFormatter
|
||||
$html = view('pages.export', [
|
||||
'page' => $page,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
@@ -114,7 +113,6 @@ class ExportFormatter
|
||||
'chapter' => $chapter,
|
||||
'pages' => $pages,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
@@ -132,7 +130,6 @@ class ExportFormatter
|
||||
'book' => $book,
|
||||
'bookChildren' => $bookTree,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
|
||||
@@ -7,15 +7,14 @@ use Barryvdh\Snappy\Facades\SnappyPdf;
|
||||
|
||||
class PdfGenerator
|
||||
{
|
||||
const ENGINE_DOMPDF = 'dompdf';
|
||||
const ENGINE_WKHTML = 'wkhtml';
|
||||
|
||||
/**
|
||||
* Generate PDF content from the given HTML content.
|
||||
*/
|
||||
public function fromHtml(string $html): string
|
||||
{
|
||||
if ($this->getActiveEngine() === self::ENGINE_WKHTML) {
|
||||
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
|
||||
|
||||
if ($useWKHTML) {
|
||||
$pdf = SnappyPDF::loadHTML($html);
|
||||
$pdf->setOption('print-media-type', true);
|
||||
} else {
|
||||
@@ -24,15 +23,4 @@ class PdfGenerator
|
||||
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active PDF engine.
|
||||
* Returns the value of an `ENGINE_` const on this class.
|
||||
*/
|
||||
public function getActiveEngine(): string
|
||||
{
|
||||
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
|
||||
|
||||
return $useWKHTML ? self::ENGINE_WKHTML : self::ENGINE_DOMPDF;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,9 @@ class TrashCan
|
||||
{
|
||||
/**
|
||||
* Send a shelf to the recycle bin.
|
||||
*
|
||||
* @throws NotifyException
|
||||
*/
|
||||
public function softDestroyShelf(Bookshelf $shelf)
|
||||
{
|
||||
$this->ensureDeletable($shelf);
|
||||
Deletion::createForEntity($shelf);
|
||||
$shelf->delete();
|
||||
}
|
||||
@@ -39,7 +36,6 @@ class TrashCan
|
||||
*/
|
||||
public function softDestroyBook(Book $book)
|
||||
{
|
||||
$this->ensureDeletable($book);
|
||||
Deletion::createForEntity($book);
|
||||
|
||||
foreach ($book->pages as $page) {
|
||||
@@ -61,7 +57,6 @@ class TrashCan
|
||||
public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
|
||||
{
|
||||
if ($recordDelete) {
|
||||
$this->ensureDeletable($chapter);
|
||||
Deletion::createForEntity($chapter);
|
||||
}
|
||||
|
||||
@@ -82,47 +77,19 @@ class TrashCan
|
||||
public function softDestroyPage(Page $page, bool $recordDelete = true)
|
||||
{
|
||||
if ($recordDelete) {
|
||||
$this->ensureDeletable($page);
|
||||
Deletion::createForEntity($page);
|
||||
}
|
||||
|
||||
$page->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the given entity is deletable.
|
||||
* Is not for permissions, but logical conditions within the application.
|
||||
* Will throw if not deletable.
|
||||
*
|
||||
* @throws NotifyException
|
||||
*/
|
||||
protected function ensureDeletable(Entity $entity): void
|
||||
{
|
||||
$customHomeId = intval(explode(':', setting('app-homepage', '0:'))[0]);
|
||||
$customHomeActive = setting('app-homepage-type') === 'page';
|
||||
$removeCustomHome = false;
|
||||
|
||||
// Check custom homepage usage for pages
|
||||
if ($entity instanceof Page && $entity->id === $customHomeId) {
|
||||
if ($customHomeActive) {
|
||||
throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
|
||||
// Check if set as custom homepage & remove setting if not used or throw error if active
|
||||
$customHome = setting('app-homepage', '0:');
|
||||
if (intval($page->id) === intval(explode(':', $customHome)[0])) {
|
||||
if (setting('app-homepage-type') === 'page') {
|
||||
throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
|
||||
}
|
||||
$removeCustomHome = true;
|
||||
}
|
||||
|
||||
// Check custom homepage usage within chapters or books
|
||||
if ($entity instanceof Chapter || $entity instanceof Book) {
|
||||
if ($entity->pages()->where('id', '=', $customHomeId)->exists()) {
|
||||
if ($customHomeActive) {
|
||||
throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
|
||||
}
|
||||
$removeCustomHome = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($removeCustomHome) {
|
||||
setting()->remove('app-homepage');
|
||||
}
|
||||
|
||||
$page->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,6 @@ use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
@@ -365,22 +364,15 @@ class PageController extends Controller
|
||||
*/
|
||||
public function showRecentlyUpdated()
|
||||
{
|
||||
$visibleBelongsScope = function (BelongsTo $query) {
|
||||
$query->scopes('visible');
|
||||
};
|
||||
|
||||
$pages = Page::visible()->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
|
||||
->orderBy('updated_at', 'desc')
|
||||
$pages = Page::visible()->orderBy('updated_at', 'desc')
|
||||
->paginate(20)
|
||||
->setPath(url('/pages/recently-updated'));
|
||||
|
||||
$this->setPageTitle(trans('entities.recently_updated_pages'));
|
||||
|
||||
return view('common.detailed-listing-paginated', [
|
||||
'title' => trans('entities.recently_updated_pages'),
|
||||
'entities' => $pages,
|
||||
'showUpdatedBy' => true,
|
||||
'showPath' => true,
|
||||
'title' => trans('entities.recently_updated_pages'),
|
||||
'entities' => $pages,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -62,7 +61,6 @@ class UserController extends Controller
|
||||
$this->checkPermission('users-manage');
|
||||
$authMethod = config('auth.method');
|
||||
$roles = $this->userRepo->getAllRoles();
|
||||
$this->setPageTitle(trans('settings.users_add_new'));
|
||||
|
||||
return view('users.create', ['authMethod' => $authMethod, 'roles' => $roles]);
|
||||
}
|
||||
@@ -77,9 +75,8 @@ class UserController extends Controller
|
||||
{
|
||||
$this->checkPermission('users-manage');
|
||||
$validationRules = [
|
||||
'name' => ['required'],
|
||||
'email' => ['required', 'email', 'unique:users,email'],
|
||||
'setting' => ['array'],
|
||||
'name' => ['required'],
|
||||
'email' => ['required', 'email', 'unique:users,email'],
|
||||
];
|
||||
|
||||
$authMethod = config('auth.method');
|
||||
@@ -102,30 +99,20 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
$user->refreshSlug();
|
||||
$user->save();
|
||||
|
||||
DB::transaction(function () use ($user, $sendInvite, $request) {
|
||||
$user->save();
|
||||
if ($sendInvite) {
|
||||
$this->inviteService->sendInvitation($user);
|
||||
}
|
||||
|
||||
// Save user-specific settings
|
||||
if ($request->filled('setting')) {
|
||||
foreach ($request->get('setting') as $key => $value) {
|
||||
setting()->putUser($user, $key, $value);
|
||||
}
|
||||
}
|
||||
if ($request->filled('roles')) {
|
||||
$roles = $request->get('roles');
|
||||
$this->userRepo->setUserRoles($user, $roles);
|
||||
}
|
||||
|
||||
if ($sendInvite) {
|
||||
$this->inviteService->sendInvitation($user);
|
||||
}
|
||||
$this->userRepo->downloadAndAssignUserAvatar($user);
|
||||
|
||||
if ($request->filled('roles')) {
|
||||
$roles = $request->get('roles');
|
||||
$this->userRepo->setUserRoles($user, $roles);
|
||||
}
|
||||
|
||||
$this->userRepo->downloadAndAssignUserAvatar($user);
|
||||
|
||||
$this->logActivity(ActivityType::USER_CREATE, $user);
|
||||
});
|
||||
$this->logActivity(ActivityType::USER_CREATE, $user);
|
||||
|
||||
return redirect('/settings/users');
|
||||
}
|
||||
@@ -207,7 +194,7 @@ class UserController extends Controller
|
||||
$user->external_auth_id = $request->get('external_auth_id');
|
||||
}
|
||||
|
||||
// Save user-specific settings
|
||||
// Save an user-specific settings
|
||||
if ($request->filled('setting')) {
|
||||
foreach ($request->get('setting') as $key => $value) {
|
||||
setting()->putUser($user, $key, $value);
|
||||
|
||||
@@ -2,33 +2,35 @@
|
||||
|
||||
namespace BookStack\Notifications;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class UserInvite extends MailNotification
|
||||
{
|
||||
public $token;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*
|
||||
* @param string $token
|
||||
*/
|
||||
public function __construct(string $token)
|
||||
public function __construct($token)
|
||||
{
|
||||
$this->token = $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
$appName = ['appName' => setting('app-name')];
|
||||
$language = setting()->getUser($notifiable, 'language');
|
||||
|
||||
return $this->newMailMessage()
|
||||
->subject(trans('auth.user_invite_email_subject', $appName, $language))
|
||||
->greeting(trans('auth.user_invite_email_greeting', $appName, $language))
|
||||
->line(trans('auth.user_invite_email_text', [], $language))
|
||||
->action(trans('auth.user_invite_email_action', [], $language), url('/register/invite/' . $this->token));
|
||||
->subject(trans('auth.user_invite_email_subject', $appName))
|
||||
->greeting(trans('auth.user_invite_email_greeting', $appName))
|
||||
->line(trans('auth.user_invite_email_text'))
|
||||
->action(trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Auth\Access\LdapService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\HttpFetchException;
|
||||
use Exception;
|
||||
@@ -16,6 +17,7 @@ class UserAvatars
|
||||
{
|
||||
$this->imageService = $imageService;
|
||||
$this->http = $http;
|
||||
$ldapService = app()->make(LdapService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"socialiteproviders/okta": "^4.1",
|
||||
"socialiteproviders/slack": "^4.1",
|
||||
"socialiteproviders/twitch": "^5.3",
|
||||
"ssddanbrown/htmldiff": "^1.0.2"
|
||||
"ssddanbrown/htmldiff": "^1.0.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.16",
|
||||
|
||||
849
composer.lock
generated
849
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,6 @@ RUN apt-get update -y \
|
||||
&& apt-get install -y git zip unzip libpng-dev libldap2-dev libzip-dev wait-for-it \
|
||||
&& docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \
|
||||
&& docker-php-ext-install pdo_mysql gd ldap zip \
|
||||
&& pecl install xdebug \
|
||||
&& docker-php-ext-enable xdebug \
|
||||
&& a2enmod rewrite \
|
||||
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
|
||||
&& sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
zend_extension=xdebug
|
||||
|
||||
[xdebug]
|
||||
xdebug.mode=debug
|
||||
xdebug.client_host=host.docker.internal
|
||||
xdebug.start_with_request=yes
|
||||
xdebug.client_port=9090
|
||||
@@ -38,10 +38,7 @@ services:
|
||||
- ${DEV_PORT:-8080}:80
|
||||
volumes:
|
||||
- ./:/app
|
||||
- ./dev/docker/php/conf.d/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
entrypoint: /app/dev/docker/entrypoint.app.sh
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
node:
|
||||
image: node:alpine
|
||||
working_dir: /app
|
||||
|
||||
817
package-lock.json
generated
817
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -7,6 +7,8 @@
|
||||
"build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main",
|
||||
"build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
|
||||
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify",
|
||||
"build:js_editor:dev": "esbuild --bundle ./resources/js/editor.js --outfile=public/dist/editor.js --sourcemap --target=es2019 --main-fields=module,main",
|
||||
"build:js_editor:watch": "chokidar --initial \"./resources/js/editor.js\" \"./resources/js/editor/**/*.js\" -c \"npm run build:js_editor:dev\"",
|
||||
"build": "npm-run-all --parallel build:*:dev",
|
||||
"production": "npm-run-all --parallel build:*:production",
|
||||
"dev": "npm-run-all --parallel watch livereload",
|
||||
@@ -16,18 +18,27 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "^3.0",
|
||||
"esbuild": "0.14.13",
|
||||
"esbuild": "0.13.12",
|
||||
"livereload": "^0.9.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"punycode": "^2.1.1",
|
||||
"sass": "^1.49.0"
|
||||
"sass": "^1.43.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"clipboard": "^2.0.8",
|
||||
"codemirror": "^5.65.1",
|
||||
"codemirror": "^5.63.3",
|
||||
"crelt": "^1.0.5",
|
||||
"dropzone": "^5.9.3",
|
||||
"markdown-it": "^12.3.2",
|
||||
"markdown-it": "^12.2.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"prosemirror-commands": "^1.1.12",
|
||||
"prosemirror-example-setup": "^1.1.2",
|
||||
"prosemirror-markdown": "^1.6.0",
|
||||
"prosemirror-model": "^1.15.0",
|
||||
"prosemirror-schema-list": "^1.1.6",
|
||||
"prosemirror-state": "^1.3.4",
|
||||
"prosemirror-tables": "^1.1.1",
|
||||
"prosemirror-view": "^1.23.2",
|
||||
"sortablejs": "^1.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
72
public/dist/app.js
vendored
72
public/dist/app.js
vendored
File diff suppressed because one or more lines are too long
1
public/dist/export-styles.css
vendored
1
public/dist/export-styles.css
vendored
File diff suppressed because one or more lines are too long
1
public/dist/print-styles.css
vendored
1
public/dist/print-styles.css
vendored
@@ -1 +0,0 @@
|
||||
:root{--color-primary: #206ea7;--color-primary-light: rgba(32,110,167,0.15);--color-page: #206ea7;--color-page-draft: #7e50b1;--color-chapter: #af4d0d;--color-book: #077b70;--color-bookshelf: #a94747}header{display:none}html,body{font-size:12px;background-color:#fff}.page-content{margin:0 auto}.print-hidden{display:none !important}.tri-layout-container{grid-template-columns:1fr;grid-template-areas:"b";margin-inline-start:0;margin-inline-end:0;display:block}.card{box-shadow:none}.content-wrap.card{padding-inline-start:0;padding-inline-end:0}/*# sourceMappingURL=print-styles.css.map */
|
||||
1
public/dist/styles.css
vendored
1
public/dist/styles.css
vendored
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB |
24
readme.md
24
readme.md
@@ -1,7 +1,7 @@
|
||||
# BookStack
|
||||
|
||||
[](https://github.com/BookStackApp/BookStack/releases/latest)
|
||||
[](https://github.com/BookStackApp/BookStack/blob/development/LICENSE)
|
||||
[](https://github.com/BookStackApp/BookStack/blob/master/LICENSE)
|
||||
[](https://crowdin.com/project/bookstack)
|
||||
[](https://discord.gg/ztkBqR2)
|
||||
[](https://gh-stats.bookstackapp.com/)
|
||||
@@ -34,14 +34,11 @@ Big thanks to these companies for supporting the project.
|
||||
Note: Listed services are not tested, vetted nor supported by the official BookStack project in any manner.
|
||||
[View all sponsors](https://github.com/sponsors/ssddanbrown).
|
||||
|
||||
#### Silver Sponsors
|
||||
#### Silver Sponsor
|
||||
|
||||
<table><tbody><tr>
|
||||
<td><a href="https://www.diagrams.net/" target="_blank">
|
||||
<img width="400" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
|
||||
</a></td>
|
||||
<td><a href="https://cloudabove.com/hosting" target="_blank">
|
||||
<img height="100" src="https://raw.githubusercontent.com/BookStackApp/website/main/static/images/sponsors/cloudabove.svg" alt="Cloudabove">
|
||||
<img width="420" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
|
||||
</a></td>
|
||||
</tr></tbody></table>
|
||||
|
||||
@@ -82,7 +79,7 @@ Feature releases, and some patch releases, will be accompanied by a post on the
|
||||
|
||||
## 🛠️ Development & Testing
|
||||
|
||||
All development on BookStack is currently done on the `development` branch. When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
|
||||
All development on BookStack is currently done on the master branch. When it's time for a release the master branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
|
||||
|
||||
* [Node.js](https://nodejs.org/en/) v14.0+
|
||||
|
||||
@@ -159,11 +156,6 @@ Once the database has been migrated & seeded, you can run the tests like so:
|
||||
docker-compose run app php vendor/bin/phpunit
|
||||
```
|
||||
|
||||
#### Debugging
|
||||
|
||||
The docker-compose setup ships with Xdebug, which you can listen to on port 9090.
|
||||
NB : For some editors like Visual Studio Code, you might need to map your workspace folder to the /app folder within the docker container for this to work.
|
||||
|
||||
## 🌎 Translations
|
||||
|
||||
Translations for text within BookStack is managed through the [BookStack project on Crowdin](https://crowdin.com/project/bookstack). Some strings have colon-prefixed variables in such as `:userName`. Leave these values as they are as they will be replaced at run-time. Crowdin is the preferred way to provide translations, otherwise the raw translations files can be found within the `resources/lang` path.
|
||||
@@ -178,9 +170,9 @@ Feel free to create issues to request new features or to report bugs & problems.
|
||||
|
||||
Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project.
|
||||
|
||||
Pull requests should be created from the `development` branch since they will be merged back into `development` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
|
||||
Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
|
||||
|
||||
The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/development/.github/CODE_OF_CONDUCT.md).
|
||||
The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/CODE_OF_CONDUCT.md).
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
@@ -188,7 +180,7 @@ Security information for administering a BookStack instance can be found on the
|
||||
|
||||
If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates).
|
||||
|
||||
If you would like to report a security concern, details of doing so can [can be found here](https://github.com/BookStackApp/BookStack/blob/development/.github/SECURITY.md).
|
||||
If you would like to report a security concern, details of doing so can [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/SECURITY.md).
|
||||
|
||||
## ♿ Accessibility
|
||||
|
||||
@@ -206,7 +198,7 @@ The BookStack source is provided under the MIT License. The libraries used by, a
|
||||
|
||||
The great people that have worked to build and improve BookStack can [be seen here](https://github.com/BookStackApp/BookStack/graphs/contributors).
|
||||
|
||||
The wonderful people that have provided translations, either through GitHub or via Crowdin [can be seen here](https://github.com/BookStackApp/BookStack/blob/development/.github/translators.txt).
|
||||
The wonderful people that have provided translations, either through GitHub or via Crowdin [can be seen here](https://github.com/BookStackApp/BookStack/blob/master/.github/translators.txt).
|
||||
|
||||
These are the great open-source projects used to help build BookStack:
|
||||
|
||||
|
||||
@@ -41,9 +41,7 @@ class EditorToolbox {
|
||||
if (cName === tabName) this.contentElements[i].style.display = 'block';
|
||||
}
|
||||
|
||||
if (openToolbox && !this.elem.classList.contains('open')) {
|
||||
this.toggle();
|
||||
}
|
||||
if (openToolbox) this.elem.classList.add('open');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -74,10 +74,6 @@ class ImageManager {
|
||||
|
||||
this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
|
||||
|
||||
this.listContainer.addEventListener('error', event => {
|
||||
event.target.src = baseUrl('loading_error.png');
|
||||
}, true);
|
||||
|
||||
onSelect(this.selectButton, () => {
|
||||
if (this.callback) {
|
||||
this.callback(this.lastSelected);
|
||||
@@ -122,9 +118,6 @@ class ImageManager {
|
||||
};
|
||||
|
||||
const {data: html} = await window.$http.get(`images/${this.type}`, params);
|
||||
if (params.page === 1) {
|
||||
this.listContainer.innerHTML = '';
|
||||
}
|
||||
this.addReturnedHtmlElementsToList(html);
|
||||
removeLoading(this.listContainer);
|
||||
}
|
||||
|
||||
@@ -112,11 +112,6 @@ class MarkdownEditor {
|
||||
if (scrollText) {
|
||||
this.scrollToText(scrollText);
|
||||
}
|
||||
|
||||
// Refresh CodeMirror on container resize
|
||||
const resizeDebounced = debounce(() => code.updateLayout(this.cm), 100, false);
|
||||
const observer = new ResizeObserver(resizeDebounced);
|
||||
observer.observe(this.elem);
|
||||
}
|
||||
|
||||
// Update the input content and render the display.
|
||||
@@ -400,9 +395,8 @@ class MarkdownEditor {
|
||||
actionInsertImage() {
|
||||
const cursorPos = this.cm.getCursor('from');
|
||||
window.ImageManager.show(image => {
|
||||
const imageUrl = image.thumbs.display || image.url;
|
||||
let selectedText = this.cm.getSelection();
|
||||
let newText = "[](" + image.url + ")";
|
||||
let newText = "[](" + image.url + ")";
|
||||
this.cm.focus();
|
||||
this.cm.replaceSelection(newText);
|
||||
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
|
||||
|
||||
@@ -563,9 +563,8 @@ class WysiwygEditor {
|
||||
}
|
||||
|
||||
// Replace the actively selected content with the linked image
|
||||
const imageUrl = image.thumbs.display || image.url;
|
||||
let html = `<a href="${image.url}" target="_blank">`;
|
||||
html += `<img src="${imageUrl}" alt="${image.name}">`;
|
||||
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
|
||||
html += '</a>';
|
||||
win.tinyMCE.activeEditor.execCommand('mceInsertContent', false, html);
|
||||
}, 'gallery');
|
||||
@@ -724,9 +723,8 @@ class WysiwygEditor {
|
||||
tooltip: 'Insert an image',
|
||||
onclick: function () {
|
||||
window.ImageManager.show(function (image) {
|
||||
const imageUrl = image.thumbs.display || image.url;
|
||||
let html = `<a href="${image.url}" target="_blank">`;
|
||||
html += `<img src="${imageUrl}" alt="${image.name}">`;
|
||||
html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
|
||||
html += '</a>';
|
||||
editor.execCommand('mceInsertContent', false, html);
|
||||
}, 'gallery');
|
||||
|
||||
18
resources/js/editor.js
Normal file
18
resources/js/editor.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import MarkdownView from "./editor/MarkdownView";
|
||||
import ProseMirrorView from "./editor/ProseMirrorView";
|
||||
|
||||
// Next step: https://prosemirror.net/examples/menu/
|
||||
|
||||
const place = document.querySelector("#editor");
|
||||
let view = new ProseMirrorView(place, document.getElementById('content').innerHTML);
|
||||
|
||||
const markdownToggle = document.getElementById('markdown-toggle');
|
||||
markdownToggle.addEventListener('change', event => {
|
||||
const View = markdownToggle.checked ? MarkdownView : ProseMirrorView;
|
||||
if (view instanceof View) return
|
||||
const content = view.content
|
||||
console.log(content);
|
||||
view.destroy()
|
||||
view = new View(place, content)
|
||||
view.focus()
|
||||
});
|
||||
28
resources/js/editor/MarkdownView.js
Normal file
28
resources/js/editor/MarkdownView.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import {htmlToDoc, docToHtml} from "./util";
|
||||
|
||||
import parser from "./markdown-parser";
|
||||
import serializer from "./markdown-serializer";
|
||||
|
||||
class MarkdownView {
|
||||
constructor(target, content) {
|
||||
// Build DOM from content
|
||||
const htmlDoc = htmlToDoc(content);
|
||||
const markdown = serializer.serialize(htmlDoc);
|
||||
|
||||
this.textarea = target.appendChild(document.createElement("textarea"))
|
||||
this.textarea.value = markdown;
|
||||
this.textarea.style.width = '1000px';
|
||||
this.textarea.style.height = '1000px';
|
||||
}
|
||||
|
||||
get content() {
|
||||
const markdown = this.textarea.value;
|
||||
const doc = parser.parse(markdown);
|
||||
return docToHtml(doc);
|
||||
}
|
||||
|
||||
focus() { this.textarea.focus() }
|
||||
destroy() { this.textarea.remove() }
|
||||
}
|
||||
|
||||
export default MarkdownView;
|
||||
52
resources/js/editor/ProseMirrorView.js
Normal file
52
resources/js/editor/ProseMirrorView.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import {EditorState} from "prosemirror-state";
|
||||
import {EditorView} from "prosemirror-view";
|
||||
import {exampleSetup} from "prosemirror-example-setup";
|
||||
import {tableEditing} from "prosemirror-tables";
|
||||
|
||||
import {DOMParser} from "prosemirror-model";
|
||||
|
||||
import schema from "./schema";
|
||||
import menu from "./menu";
|
||||
import nodeViews from "./node-views";
|
||||
import {stateToHtml} from "./util";
|
||||
import {columnResizing} from "./plugins/table-resizing";
|
||||
|
||||
class ProseMirrorView {
|
||||
constructor(target, content) {
|
||||
|
||||
// Build DOM from content
|
||||
const renderDoc = document.implementation.createHTMLDocument();
|
||||
renderDoc.body.innerHTML = content;
|
||||
|
||||
this.view = new EditorView(target, {
|
||||
state: EditorState.create({
|
||||
doc: DOMParser.fromSchema(schema).parse(renderDoc.body),
|
||||
plugins: [
|
||||
...exampleSetup({schema, menuBar: false}),
|
||||
menu,
|
||||
columnResizing(),
|
||||
tableEditing(),
|
||||
]
|
||||
}),
|
||||
nodeViews,
|
||||
});
|
||||
|
||||
// Fix for native handles (Such as table size handling) in some browsers
|
||||
document.execCommand("enableObjectResizing", false, "false")
|
||||
document.execCommand("enableInlineTableEditing", false, "false")
|
||||
}
|
||||
|
||||
get content() {
|
||||
return stateToHtml(this.view.state);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.view.focus()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.view.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export default ProseMirrorView;
|
||||
102
resources/js/editor/commands.js
Normal file
102
resources/js/editor/commands.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @param {String} attrName
|
||||
* @param {String} attrValue
|
||||
* @return {PmCommandHandler}
|
||||
*/
|
||||
export function setBlockAttr(attrName, attrValue) {
|
||||
return function (state, dispatch) {
|
||||
const ref = state.selection;
|
||||
const from = ref.from;
|
||||
const to = ref.to;
|
||||
let applicable = false;
|
||||
|
||||
state.doc.nodesBetween(from, to, function (node, pos) {
|
||||
if (applicable) {
|
||||
return false
|
||||
}
|
||||
if (!node.isTextblock || node.attrs[attrName] === attrValue) {
|
||||
return
|
||||
}
|
||||
|
||||
applicable = node.attrs[attrName] !== undefined;
|
||||
});
|
||||
|
||||
if (!applicable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
tr.doc.nodesBetween(from, to, function (node, pos) {
|
||||
const nodeAttrs = Object.assign({}, node.attrs);
|
||||
if (node.attrs[attrName] !== undefined) {
|
||||
nodeAttrs[attrName] = attrValue;
|
||||
tr.setBlockType(pos, pos + 1, node.type, nodeAttrs)
|
||||
}
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmNodeType} blockType
|
||||
* @return {PmCommandHandler}
|
||||
*/
|
||||
export function insertBlockBefore(blockType) {
|
||||
return function (state, dispatch) {
|
||||
const startPosition = state.selection.$from.before(1);
|
||||
|
||||
if (dispatch) {
|
||||
dispatch(state.tr.insert(startPosition, blockType.create()));
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} rows
|
||||
* @param {Number} columns
|
||||
* @param {Object} tableAttrs
|
||||
* @return {PmCommandHandler}
|
||||
*/
|
||||
export function insertTable(rows, columns, tableAttrs) {
|
||||
return function (state, dispatch) {
|
||||
if (!dispatch) return true;
|
||||
|
||||
const tr = state.tr;
|
||||
const nodes = state.schema.nodes;
|
||||
|
||||
const rowNodes = [];
|
||||
for (let y = 0; y < rows; y++) {
|
||||
const rowCells = [];
|
||||
for (let x = 0; x < columns; x++) {
|
||||
const cellText = nodes.paragraph.create(null);
|
||||
rowCells.push(nodes.table_cell.create(null, cellText));
|
||||
}
|
||||
rowNodes.push(nodes.table_row.create(null, rowCells));
|
||||
}
|
||||
|
||||
const table = nodes.table.create(tableAttrs, rowNodes);
|
||||
tr.replaceSelectionWith(table);
|
||||
dispatch(tr);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {PmCommandHandler}
|
||||
*/
|
||||
export function removeMarks() {
|
||||
return function (state, dispatch) {
|
||||
if (dispatch) {
|
||||
dispatch(state.tr.removeMark(state.selection.from, state.selection.to, null));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
69
resources/js/editor/markdown-parser.js
Normal file
69
resources/js/editor/markdown-parser.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import schema from "./schema";
|
||||
import markdownit from "markdown-it";
|
||||
import {MarkdownParser, defaultMarkdownParser} from "prosemirror-markdown";
|
||||
import {htmlToDoc, KeyedMultiStack} from "./util";
|
||||
|
||||
const tokens = defaultMarkdownParser.tokens;
|
||||
|
||||
// These are really a placeholder on the object to allow the below
|
||||
// parser.tokenHandlers.html_[block/inline] hacks to work as desired.
|
||||
tokens.html_block = {block: "callout", noCloseToken: true};
|
||||
tokens.html_inline = {mark: "underline"};
|
||||
|
||||
const tokenizer = markdownit("commonmark", {html: true});
|
||||
const parser = new MarkdownParser(schema, tokenizer, tokens);
|
||||
|
||||
// When we come across HTML blocks we use the document schema to parse them
|
||||
// into nodes then re-add those back into the parser state.
|
||||
parser.tokenHandlers.html_block = function(state, tok, tokens, i) {
|
||||
const contentDoc = htmlToDoc(tok.content || '');
|
||||
for (const node of contentDoc.content.content) {
|
||||
state.addNode(node.type, node.attrs, node.content);
|
||||
}
|
||||
};
|
||||
|
||||
// When we come across inline HTML we parse out the tag and keep track of
|
||||
// that in a stack, along with the marks they parse out to.
|
||||
// We open/close the marks within the state depending on the tag open/close type.
|
||||
const tagStack = new KeyedMultiStack();
|
||||
parser.tokenHandlers.html_inline = function(state, tok, tokens, i) {
|
||||
const isClosing = tok.content.startsWith('</');
|
||||
const isSelfClosing = tok.content.endsWith('/>');
|
||||
const tagName = parseTagNameFromHtmlTokenContent(tok.content);
|
||||
|
||||
if (!isClosing) {
|
||||
const completeTag = isSelfClosing ? tok.content : `${tok.content}a</${tagName}>`;
|
||||
const marks = extractMarksFromHtml(completeTag);
|
||||
tagStack.push(tagName, marks);
|
||||
for (const mark of marks) {
|
||||
state.openMark(mark);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelfClosing || isClosing) {
|
||||
const marks = (tagStack.pop(tagName) || []).reverse();
|
||||
for (const mark of marks) {
|
||||
state.closeMark(mark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} html
|
||||
* @return {PmMark[]}
|
||||
*/
|
||||
function extractMarksFromHtml(html) {
|
||||
const contentDoc = htmlToDoc('<p>' + (html || '') + '</p>');
|
||||
const marks = contentDoc?.content?.content?.[0]?.content?.content?.[0]?.marks;
|
||||
return marks || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tokenContent
|
||||
* @return {string}
|
||||
*/
|
||||
function parseTagNameFromHtmlTokenContent(tokenContent) {
|
||||
return tokenContent.split(' ')[0].replace(/[<>\/]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
export default parser;
|
||||
138
resources/js/editor/markdown-serializer.js
Normal file
138
resources/js/editor/markdown-serializer.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import {MarkdownSerializer, defaultMarkdownSerializer, MarkdownSerializerState} from "prosemirror-markdown";
|
||||
import {docToHtml} from "./util";
|
||||
|
||||
const nodes = defaultMarkdownSerializer.nodes;
|
||||
const marks = defaultMarkdownSerializer.marks;
|
||||
|
||||
|
||||
nodes.callout = function (state, node) {
|
||||
writeNodeAsHtml(state, node);
|
||||
};
|
||||
|
||||
nodes.table = function (state, node) {
|
||||
writeNodeAsHtml(state, node);
|
||||
};
|
||||
|
||||
nodes.iframe = function (state, node) {
|
||||
writeNodeAsHtml(state, node);
|
||||
};
|
||||
|
||||
nodes.details = function (state, node) {
|
||||
wrapNodeWithHtml(state, node, '<details>', '</details>');
|
||||
};
|
||||
|
||||
nodes.details_summary = function(state, node) {
|
||||
writeNodeAsHtml(state, node);
|
||||
};
|
||||
|
||||
function isPlainURL(link, parent, index, side) {
|
||||
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) {
|
||||
return false
|
||||
}
|
||||
const content = parent.child(index + (side < 0 ? -1 : 0));
|
||||
if (!content.isText || content.text != link.attrs.href || content.marks[content.marks.length - 1] != link) {
|
||||
return false
|
||||
}
|
||||
if (index == (side < 0 ? 1 : parent.childCount - 1)) {
|
||||
return true
|
||||
}
|
||||
const next = parent.child(index + (side < 0 ? -2 : 1));
|
||||
return !link.isInSet(next.marks)
|
||||
}
|
||||
|
||||
marks.link = {
|
||||
open(state, mark, parent, index) {
|
||||
const attrs = mark.attrs;
|
||||
if (attrs.target) {
|
||||
return `<a href="${attrs.target}" ${attrs.title ? `title="${attrs.title}"` : ''} target="${attrs.target}">`
|
||||
}
|
||||
return isPlainURL(mark, parent, index, 1) ? "<" : "["
|
||||
},
|
||||
close(state, mark, parent, index) {
|
||||
if (mark.attrs.target) {
|
||||
return `</a>`;
|
||||
}
|
||||
return isPlainURL(mark, parent, index, -1) ? ">"
|
||||
: "](" + state.esc(mark.attrs.href) + (mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") + ")"
|
||||
}
|
||||
};
|
||||
|
||||
marks.underline = {
|
||||
open: '<span style="text-decoration: underline;">',
|
||||
close: '</span>',
|
||||
};
|
||||
|
||||
marks.strike = {
|
||||
open: '<span style="text-decoration: line-through;">',
|
||||
close: '</span>',
|
||||
};
|
||||
|
||||
marks.superscript = {
|
||||
open: '<sup>',
|
||||
close: '</sup>',
|
||||
};
|
||||
|
||||
marks.subscript = {
|
||||
open: '<sub>',
|
||||
close: '</sub>',
|
||||
};
|
||||
|
||||
marks.text_color = {
|
||||
open(state, mark, parent, index) {
|
||||
return `<span style="color: ${mark.attrs.color};">`
|
||||
},
|
||||
close: '</span>',
|
||||
};
|
||||
|
||||
marks.background_color = {
|
||||
open(state, mark, parent, index) {
|
||||
return `<span style="background-color: ${mark.attrs.color};">`
|
||||
},
|
||||
close: '</span>',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {MarkdownSerializerState} state
|
||||
* @param {PmNode} node
|
||||
*/
|
||||
function writeNodeAsHtml(state, node) {
|
||||
const html = docToHtml({content: [node]});
|
||||
state.write(html);
|
||||
state.ensureNewLine();
|
||||
state.write('\n');
|
||||
state.closeBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MarkdownSerializerState} state
|
||||
* @param {PmNode} node
|
||||
* @param {String} openTag
|
||||
* @param {String} closeTag
|
||||
*/
|
||||
function wrapNodeWithHtml(state, node, openTag, closeTag) {
|
||||
state.write(openTag);
|
||||
state.ensureNewLine();
|
||||
state.renderContent(node);
|
||||
state.write(closeTag);
|
||||
state.closeBlock();
|
||||
state.ensureNewLine();
|
||||
state.write('\n');
|
||||
}
|
||||
|
||||
// Update serializers to just write out as HTML if we have an attribute
|
||||
// or element that cannot be represented in commonmark without losing
|
||||
// formatting or content.
|
||||
for (const [nodeType, serializerFunction] of Object.entries(nodes)) {
|
||||
nodes[nodeType] = function (state, node, parent, index) {
|
||||
if (node.attrs.align || node.attrs.height || node.attrs.width) {
|
||||
writeNodeAsHtml(state, node);
|
||||
} else {
|
||||
serializerFunction(state, node, parent, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const serializer = new MarkdownSerializer(nodes, marks);
|
||||
|
||||
export default serializer;
|
||||
62
resources/js/editor/menu/ColorPickerGrid.js
Normal file
62
resources/js/editor/menu/ColorPickerGrid.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import crel from "crelt"
|
||||
import {prefix} from "./menu-utils";
|
||||
import {TextSelection} from "prosemirror-state"
|
||||
import {expandSelectionToMark} from "../util";
|
||||
|
||||
|
||||
class ColorPickerGrid {
|
||||
|
||||
constructor(markType, attrName, colors) {
|
||||
this.markType = markType;
|
||||
this.colors = colors
|
||||
this.attrName = attrName;
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
|
||||
const colorElems = [];
|
||||
for (const color of this.colors) {
|
||||
const elem = crel("div", {class: prefix + "-color-grid-item", style: `background-color: ${color};`});
|
||||
colorElems.push(elem);
|
||||
}
|
||||
|
||||
const wrap = crel("div", {class: prefix + "-color-grid-container"}, colorElems);
|
||||
wrap.addEventListener('click', event => {
|
||||
if (event.target.classList.contains(prefix + "-color-grid-item")) {
|
||||
const color = event.target.style.backgroundColor;
|
||||
this.onColorSelect(view, color);
|
||||
}
|
||||
});
|
||||
|
||||
function update(state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {dom: wrap, update}
|
||||
}
|
||||
|
||||
onColorSelect(view, color) {
|
||||
const attrs = {[this.attrName]: color};
|
||||
const selection = view.state.selection;
|
||||
const {from, to} = expandSelectionToMark(view.state, selection, this.markType);
|
||||
const tr = view.state.tr;
|
||||
|
||||
const currentColorMarks = selection.$from.marksAcross(selection.$to) || [];
|
||||
const activeRelevantMark = currentColorMarks.filter(mark => {
|
||||
return mark.type === this.markType;
|
||||
})[0];
|
||||
const colorIsActive = activeRelevantMark && activeRelevantMark.attrs[this.attrName] === color;
|
||||
|
||||
tr.removeMark(from, to, this.markType);
|
||||
if (!colorIsActive) {
|
||||
tr.addMark(from, to, this.markType.create(attrs));
|
||||
}
|
||||
|
||||
tr.setSelection(TextSelection.create(tr.doc, from, to));
|
||||
view.dispatch(tr);
|
||||
}
|
||||
}
|
||||
|
||||
export default ColorPickerGrid;
|
||||
59
resources/js/editor/menu/DialogBox.js
Normal file
59
resources/js/editor/menu/DialogBox.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
import {prefix, renderItems} from "./menu-utils";
|
||||
import crel from "crelt";
|
||||
import {getIcon, icons} from "./icons";
|
||||
|
||||
class DialogBox {
|
||||
// :: ([MenuElement], ?Object)
|
||||
// The following options are recognized:
|
||||
//
|
||||
// **`label`**`: string`
|
||||
// : The label to show on the dialog.
|
||||
// **`closer`**`: function`
|
||||
// : The function to run when the dialog should close.
|
||||
constructor(content, options) {
|
||||
this.options = options || {};
|
||||
this.content = Array.isArray(content) ? content : [content];
|
||||
|
||||
this.closeMouseDownListener = null;
|
||||
this.wrap = null;
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
const items = renderItems(this.content, view)
|
||||
|
||||
const titleText = crel("div", {class: prefix + "-dialog-title-text"}, this.options.label);
|
||||
const titleClose = crel("button", {class: prefix + "-dialog-title-close primary-background", type: "button"}, getIcon(icons.close));
|
||||
const titleContent = crel("div", {class: prefix + "-dialog-title"}, titleText, titleClose);
|
||||
const dialog = crel("div", {class: prefix + "-dialog"}, titleContent,
|
||||
crel("div", {class: prefix + "-dialog-content"}, items.dom));
|
||||
const wrap = crel("div", {class: prefix + "-dialog-wrap"}, dialog);
|
||||
this.wrap = wrap;
|
||||
|
||||
this.closeMouseDownListener = (event) => {
|
||||
if (!dialog.contains(event.target) || titleClose.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
wrap.addEventListener("click", this.closeMouseDownListener);
|
||||
|
||||
function update(state) {
|
||||
let inner = items.update(state)
|
||||
wrap.style.display = inner ? "" : "none"
|
||||
return inner;
|
||||
}
|
||||
return {dom: wrap, update}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.options.closer) {
|
||||
this.options.closer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DialogBox;
|
||||
51
resources/js/editor/menu/DialogForm.js
Normal file
51
resources/js/editor/menu/DialogForm.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
import {prefix, renderItems} from "./menu-utils";
|
||||
import crel from "crelt";
|
||||
|
||||
class DialogForm {
|
||||
// :: ([MenuElement], ?Object)
|
||||
// The following options are recognized:
|
||||
//
|
||||
// **`action`**`: function(FormData)`
|
||||
// : The submission action to run when the form is submitted.
|
||||
// **`canceler`**`: function`
|
||||
// : The cancel action to run when the form is cancelled.
|
||||
constructor(content, options) {
|
||||
this.options = options || {};
|
||||
this.content = Array.isArray(content) ? content : [content];
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
const items = renderItems(this.content, view)
|
||||
|
||||
const formButtonCancel = crel("button", {class: prefix + "-dialog-button", type: "button"}, "Cancel");
|
||||
const formButtonSave = crel("button", {class: prefix + "-dialog-button", type: "submit"}, "Save");
|
||||
const footer = crel("div", {class: prefix + "-dialog-footer"}, formButtonCancel, formButtonSave);
|
||||
const form = crel("form", {class: prefix + "-dialog-form", action: '#'}, items.dom, footer);
|
||||
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
if (this.options.action) {
|
||||
this.options.action(new FormData(form));
|
||||
}
|
||||
});
|
||||
|
||||
formButtonCancel.addEventListener('click', event => {
|
||||
if (this.options.canceler) {
|
||||
this.options.canceler();
|
||||
}
|
||||
});
|
||||
|
||||
function update(state) {
|
||||
return items.update(state);
|
||||
}
|
||||
|
||||
return {dom: form, update}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DialogForm;
|
||||
42
resources/js/editor/menu/DialogInput.js
Normal file
42
resources/js/editor/menu/DialogInput.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
import {prefix, randHtmlId} from "./menu-utils";
|
||||
import crel from "crelt";
|
||||
|
||||
class DialogInput {
|
||||
// :: (?Object)
|
||||
// The following options are recognized:
|
||||
//
|
||||
// **`label`**`: string`
|
||||
// : The label to show for the input.
|
||||
// **`id`**`: string`
|
||||
// : The id to use for this input
|
||||
// **`attrs`**`: Object`
|
||||
// : The attributes to add to the input element.
|
||||
// **`value`**`: function(state) -> string`
|
||||
// : The getter for the input value.
|
||||
constructor(options) {
|
||||
this.options = options || {};
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
const id = randHtmlId();
|
||||
const inputAttrs = Object.assign({type: "text", name: this.options.id, id: this.options.id}, this.options.attrs || {})
|
||||
const input = crel("input", inputAttrs);
|
||||
const label = crel("label", {for: id}, this.options.label);
|
||||
|
||||
const rowRap = crel("div", {class: prefix + '-dialog-form-row'}, label, input);
|
||||
|
||||
const update = (state) => {
|
||||
input.value = this.options.value(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {dom: rowRap, update}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DialogInput;
|
||||
53
resources/js/editor/menu/DialogRadioOptions.js
Normal file
53
resources/js/editor/menu/DialogRadioOptions.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
import {prefix, randHtmlId} from "./menu-utils";
|
||||
import crel from "crelt";
|
||||
|
||||
class DialogRadioOptions {
|
||||
/**
|
||||
* Given inputOptions should be keyed by label, with values being values.
|
||||
* Values of empty string will be treated as null.
|
||||
* @param {Object} inputOptions
|
||||
* @param {{label: string, id: string, attrs?: Object, value: function(PmEditorState): string|null}} options
|
||||
*/
|
||||
constructor(inputOptions, options) {
|
||||
this.inputOptions = inputOptions;
|
||||
this.options = options || {};
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
|
||||
const inputs = [];
|
||||
const optionInputLabels = Object.keys(this.inputOptions).map(label => {
|
||||
const inputAttrs = Object.assign({
|
||||
type: "radio",
|
||||
name: this.options.id,
|
||||
value: this.inputOptions[label],
|
||||
class: prefix + '-dialog-radio-option',
|
||||
}, this.options.attrs || {});
|
||||
const input = crel("input", inputAttrs);
|
||||
inputs.push(input);
|
||||
return crel("label", input, label);
|
||||
});
|
||||
|
||||
const optionInputWrap = crel("div", {class: prefix + '-dialog-radio-option-wrap'}, optionInputLabels);
|
||||
|
||||
const label = crel("label", {}, this.options.label);
|
||||
const rowRap = crel("div", {class: prefix + '-dialog-form-row'}, label, optionInputWrap);
|
||||
|
||||
const update = (state) => {
|
||||
const value = this.options.value(state);
|
||||
for (const input of inputs) {
|
||||
input.checked = (input.value === value || (value === null && input.value === ""));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {dom: rowRap, update}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DialogRadioOptions;
|
||||
42
resources/js/editor/menu/DialogTextArea.js
Normal file
42
resources/js/editor/menu/DialogTextArea.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
import {prefix, randHtmlId} from "./menu-utils";
|
||||
import crel from "crelt";
|
||||
|
||||
class DialogTextArea {
|
||||
// :: (?Object)
|
||||
// The following options are recognized:
|
||||
//
|
||||
// **`label`**`: string`
|
||||
// : The label to show for the input.
|
||||
// **`id`**`: string`
|
||||
// : The id to use for this input
|
||||
// **`attrs`**`: Object`
|
||||
// : The attributes to add to the input element.
|
||||
// **`value`**`: function(state) -> string`
|
||||
// : The getter for the input value.
|
||||
constructor(options) {
|
||||
this.options = options || {};
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
const id = randHtmlId();
|
||||
const inputAttrs = Object.assign({type: "text", name: this.options.id, id: this.options.id}, this.options.attrs || {})
|
||||
const input = crel("textarea", inputAttrs);
|
||||
const label = this.options.label ? crel("label", {for: id}, this.options.label) : null;
|
||||
|
||||
const rowRap = crel("div", {class: prefix + '-dialog-textarea-wrap'}, label, input);
|
||||
|
||||
const update = (state) => {
|
||||
input.value = this.options.value(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {dom: rowRap, update}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DialogTextArea;
|
||||
86
resources/js/editor/menu/TableCreatorGrid.js
Normal file
86
resources/js/editor/menu/TableCreatorGrid.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import crel from "crelt"
|
||||
import {prefix} from "./menu-utils";
|
||||
import {insertTable} from "../commands";
|
||||
|
||||
class TableCreatorGrid {
|
||||
|
||||
constructor() {
|
||||
this.size = 10;
|
||||
this.label = null;
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
|
||||
const gridItems = [];
|
||||
for (let y = 0; y < this.size; y++) {
|
||||
for (let x = 0; x < this.size; x++) {
|
||||
const elem = crel("div", {class: prefix + "-table-creator-grid-item"});
|
||||
gridItems.push(elem);
|
||||
elem.addEventListener('mouseenter', event => {
|
||||
this.updateGridItemActiveStatus(elem, gridItems);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const gridWrap = crel("div", {
|
||||
class: prefix + "-table-creator-grid",
|
||||
style: `grid-template-columns: repeat(${this.size}, 14px);`,
|
||||
}, gridItems);
|
||||
|
||||
gridWrap.addEventListener('mouseleave', event => {
|
||||
this.updateGridItemActiveStatus(null, gridItems);
|
||||
});
|
||||
gridWrap.addEventListener('click', event => {
|
||||
if (event.target.classList.contains(prefix + "-table-creator-grid-item")) {
|
||||
const {x, y} = this.getPositionOfGridItem(event.target, gridItems);
|
||||
insertTable(y + 1, x + 1, {
|
||||
style: 'width: 100%;',
|
||||
})(view.state, view.dispatch);
|
||||
}
|
||||
});
|
||||
|
||||
const gridLabel = crel("div", {class: prefix + "-table-creator-grid-label"});
|
||||
this.label = gridLabel;
|
||||
const wrap = crel("div", {class: prefix + "-table-creator-grid-container"}, [gridWrap, gridLabel]);
|
||||
|
||||
function update(state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {dom: wrap, update}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element|null} newTarget
|
||||
* @param {Element[]} gridItems
|
||||
*/
|
||||
updateGridItemActiveStatus(newTarget, gridItems) {
|
||||
const {x: xPos, y: yPos} = this.getPositionOfGridItem(newTarget, gridItems);
|
||||
|
||||
for (let y = 0; y < this.size; y++) {
|
||||
for (let x = 0; x < this.size; x++) {
|
||||
const active = x <= xPos && y <= yPos;
|
||||
const index = (y * this.size) + x;
|
||||
gridItems[index].classList.toggle(prefix + "-table-creator-grid-item-active", active);
|
||||
}
|
||||
}
|
||||
|
||||
this.label.textContent = (xPos + yPos < 0) ? '' : `${xPos + 1} x ${yPos + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} gridItem
|
||||
* @param {Element[]} gridItems
|
||||
* @return {{x: number, y: number}}
|
||||
*/
|
||||
getPositionOfGridItem(gridItem, gridItems) {
|
||||
const index = gridItems.indexOf(gridItem);
|
||||
const y = Math.floor(index / this.size);
|
||||
const x = index % this.size;
|
||||
return {x, y};
|
||||
}
|
||||
}
|
||||
|
||||
export default TableCreatorGrid;
|
||||
162
resources/js/editor/menu/icons.js
Normal file
162
resources/js/editor/menu/icons.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* This file originates from https://github.com/ProseMirror/prosemirror-menu
|
||||
* and is hence subject to the MIT license found here:
|
||||
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
|
||||
* @copyright Marijn Haverbeke and others
|
||||
*/
|
||||
|
||||
// :: Object
|
||||
// A set of basic editor-related icons. Contains the properties
|
||||
// `join`, `lift`, `selectParentNode`, `undo`, `redo`, `strong`, `em`,
|
||||
// `code`, `link`, `bulletList`, `orderedList`, and `blockquote`, each
|
||||
// holding an object that can be used as the `icon` option to
|
||||
// `MenuItem`.
|
||||
export const icons = {
|
||||
undo: {
|
||||
width: 24, height: 24,
|
||||
path: "M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"
|
||||
},
|
||||
redo: {
|
||||
width: 24, height: 24,
|
||||
path: "M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"
|
||||
},
|
||||
strong: {
|
||||
width: 24, height: 24,
|
||||
path: "M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"
|
||||
},
|
||||
em: {
|
||||
width: 24, height: 24,
|
||||
path: "M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"
|
||||
},
|
||||
link: {
|
||||
width: 24, height: 24,
|
||||
path: "M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"
|
||||
},
|
||||
bullet_list: {
|
||||
width: 24, height: 24,
|
||||
path: "M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"
|
||||
},
|
||||
ordered_list: {
|
||||
width: 24, height: 24,
|
||||
path: "M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"
|
||||
},
|
||||
task_list: {
|
||||
width: 24, height: 24,
|
||||
path: "M22,7h-9v2h9V7z M22,15h-9v2h9V15z M5.54,11L2,7.46l1.41-1.41l2.12,2.12l4.24-4.24l1.41,1.41L5.54,11z M5.54,19L2,15.46 l1.41-1.41l2.12,2.12l4.24-4.24l1.41,1.41L5.54,19z"
|
||||
},
|
||||
underline: {
|
||||
width: 24, height: 24,
|
||||
path: "M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z"
|
||||
},
|
||||
strike: {
|
||||
width: 24, height: 24,
|
||||
path: "M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"
|
||||
},
|
||||
superscript: {
|
||||
width: 24, height: 24,
|
||||
path: "M22,7h-2v1h3v1h-4V7c0-0.55,0.45-1,1-1h2V5h-3V4h3c0.55,0,1,0.45,1,1v1C23,6.55,22.55,7,22,7z M5.88,20h2.66l3.4-5.42h0.12 l3.4,5.42h2.66l-4.65-7.27L17.81,6h-2.68l-3.07,4.99h-0.12L8.85,6H6.19l4.32,6.73L5.88,20z"
|
||||
},
|
||||
subscript: {
|
||||
width: 24, height: 24,
|
||||
path: "M22,18h-2v1h3v1h-4v-2c0-0.55,0.45-1,1-1h2v-1h-3v-1h3c0.55,0,1,0.45,1,1v1C23,17.55,22.55,18,22,18z M5.88,18h2.66 l3.4-5.42h0.12l3.4,5.42h2.66l-4.65-7.27L17.81,4h-2.68l-3.07,4.99h-0.12L8.85,4H6.19l4.32,6.73L5.88,18z"
|
||||
},
|
||||
text_color: {
|
||||
width: 24, height: 24,
|
||||
path: "M2,20h20v4H2V20z M5.49,17h2.42l1.27-3.58h5.65L16.09,17h2.42L13.25,3h-2.5L5.49,17z M9.91,11.39l2.03-5.79h0.12l2.03,5.79 H9.91z"
|
||||
},
|
||||
background_color: {
|
||||
width: 24, height: 24,
|
||||
path: "M16.56,8.94L7.62,0L6.21,1.41l2.38,2.38L3.44,8.94c-0.59,0.59-0.59,1.54,0,2.12l5.5,5.5C9.23,16.85,9.62,17,10,17 s0.77-0.15,1.06-0.44l5.5-5.5C17.15,10.48,17.15,9.53,16.56,8.94z M5.21,10L10,5.21L14.79,10H5.21z M19,11.5c0,0-2,2.17-2,3.5 c0,1.1,0.9,2,2,2s2-0.9,2-2C21,13.67,19,11.5,19,11.5z M2,20h20v4H2V20z"
|
||||
},
|
||||
align_left: {
|
||||
width: 24, height: 24,
|
||||
path: "M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z"
|
||||
},
|
||||
align_right: {
|
||||
width: 24, height: 24,
|
||||
path: "M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z"
|
||||
},
|
||||
align_center: {
|
||||
width: 24, height: 24,
|
||||
path: "M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z"
|
||||
},
|
||||
align_justify: {
|
||||
width: 24, height: 24,
|
||||
path: "M3 21h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18V7H3v2zm0-6v2h18V3H3z"
|
||||
},
|
||||
horizontal_rule: {
|
||||
width: 24, height: 24,
|
||||
path: "m 4,11 h 16 v 2 H 4 Z"
|
||||
},
|
||||
format_clear: {
|
||||
width: 24, height: 24,
|
||||
path: "M3.27 5L2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21 18 19.73 3.55 5.27 3.27 5zM6 5v.18L8.82 8h2.4l-.72 1.68 2.1 2.1L14.21 8H20V5H6z"
|
||||
},
|
||||
close: {
|
||||
width: 24, height: 24,
|
||||
path: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z",
|
||||
},
|
||||
source_code: {
|
||||
width: 24, height: 24,
|
||||
path: "M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z",
|
||||
},
|
||||
table: {
|
||||
width: 24, height: 24,
|
||||
path: "M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 20H4v-4h4v4zm0-6H4v-4h4v4zm0-6H4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4z",
|
||||
},
|
||||
iframe: {
|
||||
width: 24, height: 24,
|
||||
path: "m 22.71,18.43 c 0.03,-0.29 0.04,-0.58 0.01,-0.86 l 1.07,-0.85 c 0.1,-0.08 0.12,-0.21 0.06,-0.32 L 22.82,14.61 C 22.76,14.5 22.63,14.46 22.51,14.5 L 21.23,15 C 21,14.83 20.75,14.69 20.48,14.58 l -0.2,-1.36 C 20.26,13.09 20.16,13 20.03,13 h -2.07 c -0.12,0 -0.23,0.09 -0.25,0.21 l -0.2,1.36 c -0.26,0.11 -0.51,0.26 -0.74,0.42 l -1.28,-0.5 c -0.12,-0.05 -0.25,0 -0.31,0.11 l -1.03,1.79 c -0.06,0.11 -0.04,0.24 0.06,0.32 l 1.07,0.86 c -0.03,0.29 -0.04,0.58 -0.01,0.86 l -1.07,0.85 c -0.1,0.08 -0.12,0.21 -0.06,0.32 l 1.03,1.79 c 0.06,0.11 0.19,0.15 0.31,0.11 L 16.75,21 c 0.23,0.17 0.48,0.31 0.75,0.42 l 0.2,1.36 c 0.02,0.12 0.12,0.21 0.25,0.21 h 2.07 c 0.12,0 0.23,-0.09 0.25,-0.21 l 0.2,-1.36 c 0.26,-0.11 0.51,-0.26 0.74,-0.42 l 1.28,0.5 c 0.12,0.05 0.25,0 0.31,-0.11 l 1.03,-1.79 c 0.06,-0.11 0.04,-0.24 -0.06,-0.32 z M 19,19.5 c -0.83,0 -1.5,-0.67 -1.5,-1.5 0,-0.83 0.67,-1.5 1.5,-1.5 0.83,0 1.5,0.67 1.5,1.5 0,0.83 -0.67,1.5 -1.5,1.5 z M 15,12 9,8 v 8 z M 3,6 h 18 v 5 h 2 V 6 C 23,4.9 22.1,4 21,4 H 3 C 1.9,4 1,4.9 1,6 v 12 c 0,1.1 0.9,2 2,2 h 9 V 18 H 3 Z",
|
||||
},
|
||||
details: {
|
||||
width: 24, height: 24,
|
||||
path: "m 7,10 5,5 5,-5 z M 19,2.5 H 5 c -1.11,0 -2,0.9 -2,2 v 14 c 0,1.1 0.89,2 2,2 h 14 c 1.1,0 2,-0.9 2,-2 v -14 c 0,-1.1 -0.89,-2 -2,-2 z m 0,16 H 5 v -12 h 14 z",
|
||||
}
|
||||
};
|
||||
|
||||
const SVG = "http://www.w3.org/2000/svg"
|
||||
const XLINK = "http://www.w3.org/1999/xlink"
|
||||
|
||||
const prefix = "ProseMirror-icon"
|
||||
|
||||
function hashPath(path) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < path.length; i++)
|
||||
hash = (((hash << 5) - hash) + path.charCodeAt(i)) | 0
|
||||
return hash
|
||||
}
|
||||
|
||||
export function getIcon(icon) {
|
||||
let node = document.createElement("div")
|
||||
node.className = prefix
|
||||
if (icon.path) {
|
||||
let name = "pm-icon-" + hashPath(icon.path).toString(16)
|
||||
if (!document.getElementById(name)) buildSVG(name, icon)
|
||||
let svg = node.appendChild(document.createElementNS(SVG, "svg"))
|
||||
svg.style.width = (icon.width / icon.height) + "em"
|
||||
let use = svg.appendChild(document.createElementNS(SVG, "use"))
|
||||
use.setAttributeNS(XLINK, "href", /([^#]*)/.exec(document.location)[1] + "#" + name)
|
||||
} else if (icon.dom) {
|
||||
node.appendChild(icon.dom.cloneNode(true))
|
||||
} else {
|
||||
node.appendChild(document.createElement("span")).textContent = icon.text || ''
|
||||
if (icon.css) node.firstChild.style.cssText = icon.css
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function buildSVG(name, data) {
|
||||
let collection = document.getElementById(prefix + "-collection")
|
||||
if (!collection) {
|
||||
collection = document.createElementNS(SVG, "svg")
|
||||
collection.id = prefix + "-collection"
|
||||
collection.style.display = "none"
|
||||
document.body.insertBefore(collection, document.body.firstChild)
|
||||
}
|
||||
let sym = document.createElementNS(SVG, "symbol")
|
||||
sym.id = name
|
||||
sym.setAttribute("viewBox", "0 0 " + data.width + " " + data.height)
|
||||
let path = sym.appendChild(document.createElementNS(SVG, "path"))
|
||||
path.setAttribute("d", data.path)
|
||||
collection.appendChild(sym)
|
||||
}
|
||||
207
resources/js/editor/menu/index.js
Normal file
207
resources/js/editor/menu/index.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
MenuItem, Dropdown, DropdownSubmenu, renderGrouped, joinUpItem, liftItem, selectParentNodeItem,
|
||||
undoItem, redoItem, wrapItem, blockTypeItem, setAttrItem, insertBlockBeforeItem,
|
||||
} from "./menu"
|
||||
import {icons} from "./icons";
|
||||
import ColorPickerGrid from "./ColorPickerGrid";
|
||||
import TableCreatorGrid from "./TableCreatorGrid";
|
||||
import {toggleMark} from "prosemirror-commands";
|
||||
import {menuBar} from "./menubar"
|
||||
import schema from "../schema";
|
||||
import {removeMarks} from "../commands";
|
||||
|
||||
import itemAnchorButtonItem from "./item-anchor-button";
|
||||
import itemHtmlSourceButton from "./item-html-source-button";
|
||||
import itemIframeButton from "./item-iframe-button";
|
||||
|
||||
|
||||
function cmdItem(cmd, options) {
|
||||
const passedOptions = {
|
||||
label: options.title,
|
||||
run: cmd
|
||||
};
|
||||
for (const prop in options) {
|
||||
passedOptions[prop] = options[prop];
|
||||
}
|
||||
if ((!options.enable || options.enable === true) && !options.select) {
|
||||
passedOptions[options.enable ? "enable" : "select"] = function (state) {
|
||||
return cmd(state);
|
||||
};
|
||||
}
|
||||
|
||||
return new MenuItem(passedOptions)
|
||||
}
|
||||
|
||||
function markActive(state, type) {
|
||||
const ref = state.selection;
|
||||
const from = ref.from;
|
||||
const $from = ref.$from;
|
||||
const to = ref.to;
|
||||
const empty = ref.empty;
|
||||
if (empty) {
|
||||
return type.isInSet(state.storedMarks || $from.marks())
|
||||
} else {
|
||||
return state.doc.rangeHasMark(from, to, type)
|
||||
}
|
||||
}
|
||||
|
||||
function markItem(markType, options) {
|
||||
const passedOptions = {
|
||||
active: function active(state) {
|
||||
return markActive(state, markType)
|
||||
},
|
||||
enable: true
|
||||
};
|
||||
for (const prop in options) {
|
||||
passedOptions[prop] = options[prop];
|
||||
}
|
||||
|
||||
return cmdItem(toggleMark(markType, passedOptions.attrs), passedOptions)
|
||||
}
|
||||
|
||||
const inlineStyles = [
|
||||
markItem(schema.marks.strong, {title: "Bold", icon: icons.strong}),
|
||||
markItem(schema.marks.em, {title: "Italic", icon: icons.em}),
|
||||
markItem(schema.marks.underline, {title: "Underline", icon: icons.underline}),
|
||||
markItem(schema.marks.strike, {title: "Strikethrough", icon: icons.strike}),
|
||||
markItem(schema.marks.superscript, {title: "Superscript", icon: icons.superscript}),
|
||||
markItem(schema.marks.subscript, {title: "Subscript", icon: icons.subscript}),
|
||||
];
|
||||
|
||||
const formats = [
|
||||
blockTypeItem(schema.nodes.heading, {
|
||||
label: "Header Large",
|
||||
attrs: {level: 2}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.heading, {
|
||||
label: "Header Medium",
|
||||
attrs: {level: 3}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.heading, {
|
||||
label: "Header Small",
|
||||
attrs: {level: 4}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.heading, {
|
||||
label: "Header Tiny",
|
||||
attrs: {level: 5}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.paragraph, {
|
||||
label: "Paragraph",
|
||||
attrs: {}
|
||||
}),
|
||||
markItem(schema.marks.code, {
|
||||
label: "Inline Code",
|
||||
attrs: {}
|
||||
}),
|
||||
new DropdownSubmenu([
|
||||
blockTypeItem(schema.nodes.callout, {
|
||||
label: "Info Callout",
|
||||
attrs: {type: 'info'}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.callout, {
|
||||
label: "Danger Callout",
|
||||
attrs: {type: 'danger'}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.callout, {
|
||||
label: "Success Callout",
|
||||
attrs: {type: 'success'}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.callout, {
|
||||
label: "Warning Callout",
|
||||
attrs: {type: 'warning'}
|
||||
})
|
||||
], { label: 'Callouts' }),
|
||||
];
|
||||
|
||||
const alignments = [
|
||||
setAttrItem('align', 'left', {
|
||||
icon: icons.align_left
|
||||
}),
|
||||
setAttrItem('align', 'center', {
|
||||
icon: icons.align_center
|
||||
}),
|
||||
setAttrItem('align', 'right', {
|
||||
icon: icons.align_right
|
||||
}),
|
||||
setAttrItem('align', 'justify', {
|
||||
icon: icons.align_justify
|
||||
}),
|
||||
];
|
||||
|
||||
const colorOptions = ["#000000","#993300","#333300","#003300","#003366","#000080","#333399","#333333","#800000","#FF6600","#808000","#008000","#008080","#0000FF","#666699","#808080","#FF0000","#FF9900","#99CC00","#339966","#33CCCC","#3366FF","#800080","#999999","#FF00FF","#FFCC00","#FFFF00","#00FF00","#00FFFF","#00CCFF","#993366","#FFFFFF","#FF99CC","#FFCC99","#FFFF99","#CCFFCC","#CCFFFF","#99CCFF","#CC99FF"];
|
||||
|
||||
const colors = [
|
||||
new DropdownSubmenu([
|
||||
new ColorPickerGrid(schema.marks.text_color, 'color', colorOptions),
|
||||
], {icon: icons.text_color}),
|
||||
new DropdownSubmenu([
|
||||
new ColorPickerGrid(schema.marks.background_color, 'color', colorOptions),
|
||||
], {icon: icons.background_color}),
|
||||
];
|
||||
|
||||
const lists = [
|
||||
wrapItem(schema.nodes.bullet_list, {
|
||||
title: "Bullet List",
|
||||
icon: icons.bullet_list,
|
||||
}),
|
||||
wrapItem(schema.nodes.ordered_list, {
|
||||
title: "Ordered List",
|
||||
icon: icons.ordered_list,
|
||||
}),
|
||||
];
|
||||
|
||||
const inserts = [
|
||||
itemAnchorButtonItem(),
|
||||
insertBlockBeforeItem(schema.nodes.horizontal_rule, {
|
||||
title: "Horizontal Rule",
|
||||
icon: icons.horizontal_rule,
|
||||
}),
|
||||
new DropdownSubmenu([
|
||||
new TableCreatorGrid()
|
||||
], {icon: icons.table}),
|
||||
itemIframeButton(),
|
||||
wrapItem(schema.nodes.details, {
|
||||
title: "Dropdown Block",
|
||||
icon: icons.details,
|
||||
})
|
||||
];
|
||||
|
||||
const utilities = [
|
||||
new MenuItem({
|
||||
title: 'Clear Formatting',
|
||||
icon: icons.format_clear,
|
||||
run: removeMarks(),
|
||||
enable: state => true,
|
||||
}),
|
||||
itemHtmlSourceButton(),
|
||||
];
|
||||
|
||||
const menu = menuBar({
|
||||
floating: false,
|
||||
content: [
|
||||
[undoItem, redoItem],
|
||||
[new DropdownSubmenu(formats, { label: 'Formats' })],
|
||||
inlineStyles,
|
||||
colors,
|
||||
alignments,
|
||||
lists,
|
||||
inserts,
|
||||
utilities,
|
||||
],
|
||||
});
|
||||
|
||||
export default menu;
|
||||
|
||||
// !! This module defines a number of building blocks for ProseMirror
|
||||
// menus, along with a [menu bar](#menu.menuBar) implementation.
|
||||
|
||||
// MenuElement:: interface
|
||||
// The types defined in this module aren't the only thing you can
|
||||
// display in your menu. Anything that conforms to this interface can
|
||||
// be put into a menu structure.
|
||||
//
|
||||
// render:: (pm: EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Render the element for display in the menu. Must return a DOM
|
||||
// element and a function that can be used to update the element to
|
||||
// a new state. The `update` function will return false if the
|
||||
// update hid the entire element.
|
||||
120
resources/js/editor/menu/item-anchor-button.js
Normal file
120
resources/js/editor/menu/item-anchor-button.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import DialogBox from "./DialogBox";
|
||||
import DialogForm from "./DialogForm";
|
||||
import DialogInput from "./DialogInput";
|
||||
import DialogRadioOptions from "./DialogRadioOptions";
|
||||
import schema from "../schema";
|
||||
|
||||
import {MenuItem} from "./menu";
|
||||
import {icons} from "./icons";
|
||||
import {expandSelectionToMark, nullifyEmptyValues} from "../util";
|
||||
|
||||
/**
|
||||
* @param {PmMarkType} markType
|
||||
* @param {String} attribute
|
||||
* @return {(function(PmEditorState): (string|null))}
|
||||
*/
|
||||
function getMarkAttribute(markType, attribute) {
|
||||
return function (state) {
|
||||
const marks = state.selection.$head.marks();
|
||||
for (const mark of marks) {
|
||||
if (mark.type === markType) {
|
||||
return mark.attrs[attribute];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(function(FormData))} submitter
|
||||
* @param {Function} closer
|
||||
* @return {DialogBox}
|
||||
*/
|
||||
function getLinkDialog(submitter, closer) {
|
||||
return new DialogBox([
|
||||
new DialogForm([
|
||||
new DialogInput({
|
||||
label: 'URL',
|
||||
id: 'href',
|
||||
value: getMarkAttribute(schema.marks.link, 'href'),
|
||||
}),
|
||||
new DialogInput({
|
||||
label: 'Hover Label',
|
||||
id: 'title',
|
||||
value: getMarkAttribute(schema.marks.link, 'title'),
|
||||
}),
|
||||
new DialogRadioOptions({
|
||||
"Same tab or window": "",
|
||||
"New tab or window": "_blank",
|
||||
}, {
|
||||
label: 'Behaviour',
|
||||
id: 'target',
|
||||
value: getMarkAttribute(schema.marks.link, 'target'),
|
||||
})
|
||||
], {
|
||||
canceler: closer,
|
||||
action: submitter,
|
||||
}),
|
||||
], {
|
||||
label: 'Insert Link',
|
||||
closer: closer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FormData} formData
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @return {boolean}
|
||||
*/
|
||||
function applyLink(formData, state, dispatch) {
|
||||
const selection = state.selection;
|
||||
const attrs = nullifyEmptyValues(Object.fromEntries(formData));
|
||||
if (!dispatch) return true;
|
||||
|
||||
const tr = state.tr;
|
||||
const {from, to} = expandSelectionToMark(state, selection, schema.marks.link);
|
||||
|
||||
if (attrs.href) {
|
||||
tr.addMark(from, to, schema.marks.link.create(attrs));
|
||||
} else {
|
||||
tr.removeMark(from, to, schema.marks.link);
|
||||
}
|
||||
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @param {PmView} view
|
||||
* @param {Event} e
|
||||
*/
|
||||
function onPress(state, dispatch, view, e) {
|
||||
const dialog = getLinkDialog((data) => {
|
||||
applyLink(data, state, dispatch);
|
||||
dom.remove();
|
||||
}, () => {
|
||||
dom.remove();
|
||||
})
|
||||
|
||||
const {dom, update} = dialog.render(view);
|
||||
update(state);
|
||||
document.body.appendChild(dom);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {MenuItem}
|
||||
*/
|
||||
function anchorButtonItem() {
|
||||
return new MenuItem({
|
||||
title: "Insert/Edit Anchor Link",
|
||||
run: onPress,
|
||||
enable: state => true,
|
||||
icon: icons.link,
|
||||
});
|
||||
}
|
||||
|
||||
export default anchorButtonItem;
|
||||
87
resources/js/editor/menu/item-html-source-button.js
Normal file
87
resources/js/editor/menu/item-html-source-button.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import DialogBox from "./DialogBox";
|
||||
import DialogForm from "./DialogForm";
|
||||
import DialogTextArea from "./DialogTextArea";
|
||||
|
||||
import {MenuItem} from "./menu";
|
||||
import {icons} from "./icons";
|
||||
import {htmlToDoc, stateToHtml} from "../util";
|
||||
|
||||
/**
|
||||
* @param {(function(FormData))} submitter
|
||||
* @param {Function} closer
|
||||
* @return {DialogBox}
|
||||
*/
|
||||
function getLinkDialog(submitter, closer) {
|
||||
return new DialogBox([
|
||||
new DialogForm([
|
||||
new DialogTextArea({
|
||||
id: 'source',
|
||||
value: stateToHtml,
|
||||
attrs: {
|
||||
rows: 10,
|
||||
cols: 50,
|
||||
}
|
||||
}),
|
||||
], {
|
||||
canceler: closer,
|
||||
action: submitter,
|
||||
}),
|
||||
], {
|
||||
label: 'View/Edit HTML Source',
|
||||
closer: closer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FormData} formData
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @return {boolean}
|
||||
*/
|
||||
function replaceEditorHtml(formData, state, dispatch) {
|
||||
const html = formData.get('source');
|
||||
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
|
||||
const newDoc = htmlToDoc(html);
|
||||
tr.replaceWith(0, state.doc.content.size, newDoc.content);
|
||||
dispatch(tr);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @param {PmView} view
|
||||
* @param {Event} e
|
||||
*/
|
||||
function onPress(state, dispatch, view, e) {
|
||||
const dialog = getLinkDialog((data) => {
|
||||
replaceEditorHtml(data, state, dispatch);
|
||||
dom.remove();
|
||||
}, () => {
|
||||
dom.remove();
|
||||
})
|
||||
|
||||
const {dom, update} = dialog.render(view);
|
||||
update(state);
|
||||
document.body.appendChild(dom);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {MenuItem}
|
||||
*/
|
||||
function htmlSourceButtonItem() {
|
||||
return new MenuItem({
|
||||
title: "View HTML Source",
|
||||
run: onPress,
|
||||
enable: state => true,
|
||||
icon: icons.source_code,
|
||||
});
|
||||
}
|
||||
|
||||
export default htmlSourceButtonItem;
|
||||
115
resources/js/editor/menu/item-iframe-button.js
Normal file
115
resources/js/editor/menu/item-iframe-button.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import DialogBox from "./DialogBox";
|
||||
import DialogForm from "./DialogForm";
|
||||
import DialogInput from "./DialogInput";
|
||||
import schema from "../schema";
|
||||
|
||||
import {MenuItem} from "./menu";
|
||||
import {icons} from "./icons";
|
||||
import {nullifyEmptyValues} from "../util";
|
||||
|
||||
/**
|
||||
* @param {PmNodeType} nodeType
|
||||
* @param {String} attribute
|
||||
* @return {(function(PmEditorState): (string|null))}
|
||||
*/
|
||||
function getNodeAttribute(nodeType, attribute) {
|
||||
return function (state) {
|
||||
const node = state.selection.node;
|
||||
if (node && node.type === nodeType) {
|
||||
return node.attrs[attribute];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(function(FormData))} submitter
|
||||
* @param {Function} closer
|
||||
* @return {DialogBox}
|
||||
*/
|
||||
function getLinkDialog(submitter, closer) {
|
||||
return new DialogBox([
|
||||
new DialogForm([
|
||||
new DialogInput({
|
||||
label: 'Source URL',
|
||||
id: 'src',
|
||||
value: getNodeAttribute(schema.nodes.iframe, 'src'),
|
||||
}),
|
||||
new DialogInput({
|
||||
label: 'Hover Label',
|
||||
id: 'title',
|
||||
value: getNodeAttribute(schema.nodes.iframe, 'title'),
|
||||
}),
|
||||
new DialogInput({
|
||||
label: 'Width',
|
||||
id: 'width',
|
||||
value: getNodeAttribute(schema.nodes.iframe, 'width'),
|
||||
}),
|
||||
new DialogInput({
|
||||
label: 'Height',
|
||||
id: 'height',
|
||||
value: getNodeAttribute(schema.nodes.iframe, 'height'),
|
||||
}),
|
||||
], {
|
||||
canceler: closer,
|
||||
action: submitter,
|
||||
}),
|
||||
], {
|
||||
label: 'Insert Embedded Content',
|
||||
closer: closer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FormData} formData
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @return {boolean}
|
||||
*/
|
||||
function applyIframe(formData, state, dispatch) {
|
||||
const attrs = nullifyEmptyValues(Object.fromEntries(formData));
|
||||
if (!dispatch) return true;
|
||||
|
||||
const tr = state.tr;
|
||||
const currentNodeAttrs = state.selection?.nodes?.attrs || {};
|
||||
const newAttrs = Object.assign({}, currentNodeAttrs, attrs);
|
||||
tr.replaceSelectionWith(schema.nodes.iframe.create(newAttrs));
|
||||
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @param {PmView} view
|
||||
* @param {Event} e
|
||||
*/
|
||||
function onPress(state, dispatch, view, e) {
|
||||
const dialog = getLinkDialog((data) => {
|
||||
applyIframe(data, state, dispatch);
|
||||
dom.remove();
|
||||
}, () => {
|
||||
dom.remove();
|
||||
})
|
||||
|
||||
const {dom, update} = dialog.render(view);
|
||||
update(state);
|
||||
document.body.appendChild(dom);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {MenuItem}
|
||||
*/
|
||||
function iframeButtonItem() {
|
||||
return new MenuItem({
|
||||
title: "Embed Content",
|
||||
run: onPress,
|
||||
enable: state => true,
|
||||
active: state => (state.selection.node || {type: ''}).type === schema.nodes.iframe,
|
||||
icon: icons.iframe,
|
||||
});
|
||||
}
|
||||
|
||||
export default iframeButtonItem;
|
||||
39
resources/js/editor/menu/menu-utils.js
Normal file
39
resources/js/editor/menu/menu-utils.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import crel from "crelt";
|
||||
|
||||
export const prefix = "ProseMirror-menu";
|
||||
|
||||
export function renderDropdownItems(items, view) {
|
||||
let rendered = [], updates = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let {dom, update} = items[i].render(view)
|
||||
rendered.push(crel("div", {class: prefix + "-dropdown-item"}, dom))
|
||||
updates.push(update)
|
||||
}
|
||||
return {dom: rendered, update: combineUpdates(updates, rendered)}
|
||||
}
|
||||
|
||||
export function renderItems(items, view) {
|
||||
let rendered = [], updates = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let {dom, update} = items[i].render(view)
|
||||
rendered.push(dom);
|
||||
updates.push(update)
|
||||
}
|
||||
return {dom: rendered, update: combineUpdates(updates, rendered)}
|
||||
}
|
||||
|
||||
export function combineUpdates(updates, nodes) {
|
||||
return state => {
|
||||
let something = false
|
||||
for (let i = 0; i < updates.length; i++) {
|
||||
let up = updates[i](state)
|
||||
nodes[i].style.display = up ? "" : "none"
|
||||
if (up) something = true
|
||||
}
|
||||
return something
|
||||
}
|
||||
}
|
||||
|
||||
export function randHtmlId() {
|
||||
return Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 9);
|
||||
}
|
||||
419
resources/js/editor/menu/menu.js
Normal file
419
resources/js/editor/menu/menu.js
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* This file originates from https://github.com/ProseMirror/prosemirror-menu
|
||||
* and is hence subject to the MIT license found here:
|
||||
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
|
||||
* @copyright Marijn Haverbeke and others
|
||||
*/
|
||||
|
||||
import crel from "crelt"
|
||||
import {lift, joinUp, selectParentNode, wrapIn, setBlockType, toggleMark} from "prosemirror-commands"
|
||||
import {undo, redo} from "prosemirror-history"
|
||||
import {setBlockAttr, insertBlockBefore} from "../commands";
|
||||
import {renderDropdownItems, combineUpdates} from "./menu-utils";
|
||||
|
||||
import {getIcon, icons} from "./icons"
|
||||
import {prefix} from "./menu-utils";
|
||||
|
||||
// ::- An icon or label that, when clicked, executes a command.
|
||||
export class MenuItem {
|
||||
// :: (MenuItemSpec)
|
||||
constructor(spec) {
|
||||
// :: MenuItemSpec
|
||||
// The spec used to create the menu item.
|
||||
this.spec = spec
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the icon according to its [display
|
||||
// spec](#menu.MenuItemSpec.display), and adds an event handler which
|
||||
// executes the command when the representation is clicked.
|
||||
render(view) {
|
||||
let spec = this.spec
|
||||
let dom = spec.render ? spec.render(view)
|
||||
: spec.icon ? getIcon(spec.icon)
|
||||
: spec.label ? crel("div", null, translate(view, spec.label))
|
||||
: null
|
||||
if (!dom) throw new RangeError("MenuItem without icon or label property")
|
||||
if (spec.title) {
|
||||
const title = (typeof spec.title === "function" ? spec.title(view.state) : spec.title)
|
||||
dom.setAttribute("title", translate(view, title))
|
||||
}
|
||||
if (spec.class) dom.classList.add(spec.class)
|
||||
if (spec.css) dom.style.cssText += spec.css
|
||||
|
||||
dom.addEventListener("mousedown", e => {
|
||||
e.preventDefault()
|
||||
if (!dom.classList.contains(prefix + "-disabled"))
|
||||
spec.run(view.state, view.dispatch, view, e)
|
||||
})
|
||||
|
||||
function update(state) {
|
||||
if (spec.select) {
|
||||
let selected = spec.select(state)
|
||||
dom.style.display = selected ? "" : "none"
|
||||
if (!selected) return false
|
||||
}
|
||||
let enabled = true
|
||||
if (spec.enable) {
|
||||
enabled = spec.enable(state) || false
|
||||
setClass(dom, prefix + "-disabled", !enabled)
|
||||
}
|
||||
if (spec.active) {
|
||||
let active = enabled && spec.active(state) || false
|
||||
setClass(dom, prefix + "-active", active)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return {dom, update}
|
||||
}
|
||||
}
|
||||
|
||||
function translate(view, text) {
|
||||
return view._props.translate ? view._props.translate(text) : text
|
||||
}
|
||||
|
||||
// MenuItemSpec:: interface
|
||||
// The configuration object passed to the `MenuItem` constructor.
|
||||
//
|
||||
// run:: (EditorState, (Transaction), EditorView, dom.Event)
|
||||
// The function to execute when the menu item is activated.
|
||||
//
|
||||
// select:: ?(EditorState) → bool
|
||||
// Optional function that is used to determine whether the item is
|
||||
// appropriate at the moment. Deselected items will be hidden.
|
||||
//
|
||||
// enable:: ?(EditorState) → bool
|
||||
// Function that is used to determine if the item is enabled. If
|
||||
// given and returning false, the item will be given a disabled
|
||||
// styling.
|
||||
//
|
||||
// active:: ?(EditorState) → bool
|
||||
// A predicate function to determine whether the item is 'active' (for
|
||||
// example, the item for toggling the strong mark might be active then
|
||||
// the cursor is in strong text).
|
||||
//
|
||||
// render:: ?(EditorView) → dom.Node
|
||||
// A function that renders the item. You must provide either this,
|
||||
// [`icon`](#menu.MenuItemSpec.icon), or [`label`](#MenuItemSpec.label).
|
||||
//
|
||||
// icon:: ?Object
|
||||
// Describes an icon to show for this item. The object may specify
|
||||
// an SVG icon, in which case its `path` property should be an [SVG
|
||||
// path
|
||||
// spec](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d),
|
||||
// and `width` and `height` should provide the viewbox in which that
|
||||
// path exists. Alternatively, it may have a `text` property
|
||||
// specifying a string of text that makes up the icon, with an
|
||||
// optional `css` property giving additional CSS styling for the
|
||||
// text. _Or_ it may contain `dom` property containing a DOM node.
|
||||
//
|
||||
// label:: ?string
|
||||
// Makes the item show up as a text label. Mostly useful for items
|
||||
// wrapped in a [drop-down](#menu.Dropdown) or similar menu. The object
|
||||
// should have a `label` property providing the text to display.
|
||||
//
|
||||
// title:: ?union<string, (EditorState) → string>
|
||||
// Defines DOM title (mouseover) text for the item.
|
||||
//
|
||||
// class:: ?string
|
||||
// Optionally adds a CSS class to the item's DOM representation.
|
||||
//
|
||||
// css:: ?string
|
||||
// Optionally adds a string of inline CSS to the item's DOM
|
||||
// representation.
|
||||
|
||||
let lastMenuEvent = {time: 0, node: null}
|
||||
function markMenuEvent(e) {
|
||||
lastMenuEvent.time = Date.now()
|
||||
lastMenuEvent.node = e.target
|
||||
}
|
||||
function isMenuEvent(wrapper) {
|
||||
return Date.now() - 100 < lastMenuEvent.time &&
|
||||
lastMenuEvent.node && wrapper.contains(lastMenuEvent.node)
|
||||
}
|
||||
|
||||
// ::- A drop-down menu, displayed as a label with a downwards-pointing
|
||||
// triangle to the right of it.
|
||||
export class Dropdown {
|
||||
// :: ([MenuElement], ?Object)
|
||||
// Create a dropdown wrapping the elements. Options may include
|
||||
// the following properties:
|
||||
//
|
||||
// **`label`**`: string`
|
||||
// : The label to show on the drop-down control.
|
||||
//
|
||||
// **`title`**`: string`
|
||||
// : Sets the
|
||||
// [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title)
|
||||
// attribute given to the menu control.
|
||||
//
|
||||
// **`class`**`: string`
|
||||
// : When given, adds an extra CSS class to the menu control.
|
||||
//
|
||||
// **`css`**`: string`
|
||||
// : When given, adds an extra set of CSS styles to the menu control.
|
||||
constructor(content, options) {
|
||||
this.options = options || {}
|
||||
this.content = Array.isArray(content) ? content : [content]
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState)}
|
||||
// Render the dropdown menu and sub-items.
|
||||
render(view) {
|
||||
let content = renderDropdownItems(this.content, view)
|
||||
|
||||
let label = crel("div", {class: prefix + "-dropdown " + (this.options.class || ""),
|
||||
style: this.options.css},
|
||||
translate(view, this.options.label))
|
||||
if (this.options.title) label.setAttribute("title", translate(view, this.options.title))
|
||||
let wrap = crel("div", {class: prefix + "-dropdown-wrap"}, label)
|
||||
let open = null, listeningOnClose = null
|
||||
let close = () => {
|
||||
if (open && open.close()) {
|
||||
open = null
|
||||
window.removeEventListener("mousedown", listeningOnClose)
|
||||
}
|
||||
}
|
||||
label.addEventListener("mousedown", e => {
|
||||
e.preventDefault()
|
||||
markMenuEvent(e)
|
||||
if (open) {
|
||||
close()
|
||||
} else {
|
||||
open = this.expand(wrap, content.dom)
|
||||
window.addEventListener("mousedown", listeningOnClose = () => {
|
||||
if (!isMenuEvent(wrap)) close()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function update(state) {
|
||||
let inner = content.update(state)
|
||||
wrap.style.display = inner ? "" : "none"
|
||||
return inner
|
||||
}
|
||||
|
||||
return {dom: wrap, update}
|
||||
}
|
||||
|
||||
expand(dom, items) {
|
||||
let menuDOM = crel("div", {class: prefix + "-dropdown-menu " + (this.options.class || "")}, items)
|
||||
|
||||
let done = false
|
||||
function close() {
|
||||
if (done) return
|
||||
done = true
|
||||
dom.removeChild(menuDOM)
|
||||
return true
|
||||
}
|
||||
dom.appendChild(menuDOM)
|
||||
return {close, node: menuDOM}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
export class DropdownSubmenu {
|
||||
// :: ([MenuElement], ?Object)
|
||||
// Creates a submenu for the given group of menu elements. The
|
||||
// following options are recognized:
|
||||
//
|
||||
// **`label`**`: string`
|
||||
// : The label to show on the submenu.
|
||||
constructor(content, options) {
|
||||
this.options = options || {}
|
||||
this.content = Array.isArray(content) ? content : [content]
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
const items = renderDropdownItems(this.content, view)
|
||||
|
||||
const handleContent = this.options.icon ? getIcon(this.options.icon) : crel("div", {class: prefix + "-submenu-label"}, translate(view, this.options.label));
|
||||
const wrap = crel("div", {class: prefix + "-submenu-wrap"}, handleContent,
|
||||
crel("div", {class: prefix + "-submenu"}, items.dom))
|
||||
let listeningOnClose = null
|
||||
handleContent.addEventListener("mousedown", e => {
|
||||
e.preventDefault()
|
||||
markMenuEvent(e)
|
||||
setClass(wrap, prefix + "-submenu-wrap-active")
|
||||
if (!listeningOnClose)
|
||||
window.addEventListener("mousedown", listeningOnClose = () => {
|
||||
if (!isMenuEvent(wrap)) {
|
||||
wrap.classList.remove(prefix + "-submenu-wrap-active")
|
||||
window.removeEventListener("mousedown", listeningOnClose)
|
||||
listeningOnClose = null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function update(state) {
|
||||
let inner = items.update(state)
|
||||
wrap.style.display = inner ? "" : "none"
|
||||
return inner
|
||||
}
|
||||
return {dom: wrap, update}
|
||||
}
|
||||
}
|
||||
|
||||
// :: (EditorView, [[MenuElement]]) → {dom: dom.DocumentFragment, update: (EditorState) → bool}
|
||||
// Render the given, possibly nested, array of menu elements into a
|
||||
// document fragment, placing separators between them (and ensuring no
|
||||
// superfluous separators appear when some of the groups turn out to
|
||||
// be empty).
|
||||
export function renderGrouped(view, content) {
|
||||
let result = document.createDocumentFragment()
|
||||
let updates = [], separators = []
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
let items = content[i], localUpdates = [], localNodes = []
|
||||
for (let j = 0; j < items.length; j++) {
|
||||
let {dom, update} = items[j].render(view)
|
||||
let span = crel("span", {class: prefix + "item"}, dom)
|
||||
result.appendChild(span)
|
||||
localNodes.push(span)
|
||||
localUpdates.push(update)
|
||||
}
|
||||
if (localUpdates.length) {
|
||||
updates.push(combineUpdates(localUpdates, localNodes))
|
||||
if (i < content.length - 1)
|
||||
separators.push(result.appendChild(separator()))
|
||||
}
|
||||
}
|
||||
|
||||
function update(state) {
|
||||
let something = false, needSep = false
|
||||
for (let i = 0; i < updates.length; i++) {
|
||||
let hasContent = updates[i](state)
|
||||
if (i) separators[i - 1].style.display = needSep && hasContent ? "" : "none"
|
||||
needSep = hasContent
|
||||
if (hasContent) something = true
|
||||
}
|
||||
return something
|
||||
}
|
||||
return {dom: result, update}
|
||||
}
|
||||
|
||||
function separator() {
|
||||
return crel("span", {class: prefix + "separator"})
|
||||
}
|
||||
|
||||
|
||||
// :: MenuItem
|
||||
// Menu item for the `joinUp` command.
|
||||
export const joinUpItem = new MenuItem({
|
||||
title: "Join with above block",
|
||||
run: joinUp,
|
||||
select: state => joinUp(state),
|
||||
icon: icons.join
|
||||
})
|
||||
|
||||
// :: MenuItem
|
||||
// Menu item for the `lift` command.
|
||||
export const liftItem = new MenuItem({
|
||||
title: "Lift out of enclosing block",
|
||||
run: lift,
|
||||
select: state => lift(state),
|
||||
icon: icons.lift
|
||||
})
|
||||
|
||||
// :: MenuItem
|
||||
// Menu item for the `selectParentNode` command.
|
||||
export const selectParentNodeItem = new MenuItem({
|
||||
title: "Select parent node",
|
||||
run: selectParentNode,
|
||||
select: state => selectParentNode(state),
|
||||
icon: icons.selectParentNode
|
||||
})
|
||||
|
||||
// :: MenuItem
|
||||
// Menu item for the `undo` command.
|
||||
export let undoItem = new MenuItem({
|
||||
title: "Undo last change",
|
||||
run: undo,
|
||||
enable: state => undo(state),
|
||||
icon: icons.undo
|
||||
})
|
||||
|
||||
// :: MenuItem
|
||||
// Menu item for the `redo` command.
|
||||
export let redoItem = new MenuItem({
|
||||
title: "Redo last undone change",
|
||||
run: redo,
|
||||
enable: state => redo(state),
|
||||
icon: icons.redo
|
||||
})
|
||||
|
||||
// :: (NodeType, Object) → MenuItem
|
||||
// Build a menu item for wrapping the selection in a given node type.
|
||||
// Adds `run` and `select` properties to the ones present in
|
||||
// `options`. `options.attrs` may be an object or a function.
|
||||
export function wrapItem(nodeType, options) {
|
||||
let passedOptions = {
|
||||
run(state, dispatch) {
|
||||
// FIXME if (options.attrs instanceof Function) options.attrs(state, attrs => wrapIn(nodeType, attrs)(state))
|
||||
return wrapIn(nodeType, options.attrs)(state, dispatch)
|
||||
},
|
||||
select(state) {
|
||||
return wrapIn(nodeType, options.attrs instanceof Function ? null : options.attrs)(state)
|
||||
}
|
||||
}
|
||||
for (let prop in options) passedOptions[prop] = options[prop]
|
||||
return new MenuItem(passedOptions)
|
||||
}
|
||||
|
||||
// :: (NodeType, Object) → MenuItem
|
||||
// Build a menu item for changing the type of the textblock around the
|
||||
// selection to the given type. Provides `run`, `active`, and `select`
|
||||
// properties. Others must be given in `options`. `options.attrs` may
|
||||
// be an object to provide the attributes for the textblock node.
|
||||
export function blockTypeItem(nodeType, options) {
|
||||
let command = setBlockType(nodeType, options.attrs)
|
||||
let passedOptions = {
|
||||
run: command,
|
||||
enable(state) { return command(state) },
|
||||
active(state) {
|
||||
let {$from, to, node} = state.selection
|
||||
if (node) return node.hasMarkup(nodeType, options.attrs)
|
||||
return to <= $from.end() && $from.parent.hasMarkup(nodeType, options.attrs)
|
||||
}
|
||||
}
|
||||
for (let prop in options) passedOptions[prop] = options[prop]
|
||||
return new MenuItem(passedOptions)
|
||||
}
|
||||
|
||||
export function setAttrItem(attrName, attrValue, options) {
|
||||
const command = setBlockAttr(attrName, attrValue);
|
||||
const passedOptions = {
|
||||
run: command,
|
||||
enable(state) { return command(state) },
|
||||
active(state) {
|
||||
const {$from, to, node} = state.selection
|
||||
if (node) return node.attrs[attrValue] === attrValue;
|
||||
return to <= $from.end() && $from.parent.attrs[attrValue] === attrValue;
|
||||
}
|
||||
}
|
||||
for (const prop in options) passedOptions[prop] = options[prop]
|
||||
return new MenuItem(passedOptions)
|
||||
}
|
||||
|
||||
export function insertBlockBeforeItem(blockType, options) {
|
||||
const command = insertBlockBefore(blockType);
|
||||
const passedOptions = {
|
||||
run: command,
|
||||
enable(state) { return command(state) },
|
||||
active(state) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const prop in options) passedOptions[prop] = options[prop]
|
||||
return new MenuItem(passedOptions);
|
||||
}
|
||||
|
||||
// Work around classList.toggle being broken in IE11
|
||||
function setClass(dom, cls, on) {
|
||||
if (on) dom.classList.add(cls)
|
||||
else dom.classList.remove(cls)
|
||||
}
|
||||
163
resources/js/editor/menu/menubar.js
Normal file
163
resources/js/editor/menu/menubar.js
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* This file originates from https://github.com/ProseMirror/prosemirror-menu
|
||||
* and is hence subject to the MIT license found here:
|
||||
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
|
||||
* @copyright Marijn Haverbeke and others
|
||||
*/
|
||||
|
||||
import crel from "crelt"
|
||||
import {Plugin} from "prosemirror-state"
|
||||
|
||||
import {renderGrouped} from "./menu"
|
||||
|
||||
const prefix = "ProseMirror-menubar"
|
||||
|
||||
function isIOS() {
|
||||
if (typeof navigator == "undefined") return false
|
||||
let agent = navigator.userAgent
|
||||
return !/Edge\/\d/.test(agent) && /AppleWebKit/.test(agent) && /Mobile\/\w+/.test(agent)
|
||||
}
|
||||
|
||||
// :: (Object) → Plugin
|
||||
// A plugin that will place a menu bar above the editor. Note that
|
||||
// this involves wrapping the editor in an additional `<div>`.
|
||||
//
|
||||
// options::-
|
||||
// Supports the following options:
|
||||
//
|
||||
// content:: [[MenuElement]]
|
||||
// Provides the content of the menu, as a nested array to be
|
||||
// passed to `renderGrouped`.
|
||||
//
|
||||
// floating:: ?bool
|
||||
// Determines whether the menu floats, i.e. whether it sticks to
|
||||
// the top of the viewport when the editor is partially scrolled
|
||||
// out of view.
|
||||
export function menuBar(options) {
|
||||
return new Plugin({
|
||||
view(editorView) { return new MenuBarView(editorView, options) }
|
||||
})
|
||||
}
|
||||
|
||||
class MenuBarView {
|
||||
constructor(editorView, options) {
|
||||
this.editorView = editorView
|
||||
this.options = options
|
||||
|
||||
this.wrapper = crel("div", {class: prefix + "-wrapper"})
|
||||
this.menu = this.wrapper.appendChild(crel("div", {class: prefix}))
|
||||
this.menu.className = prefix
|
||||
this.spacer = null
|
||||
|
||||
if (editorView.dom.parentNode)
|
||||
editorView.dom.parentNode.replaceChild(this.wrapper, editorView.dom)
|
||||
this.wrapper.appendChild(editorView.dom)
|
||||
|
||||
this.maxHeight = 0
|
||||
this.widthForMaxHeight = 0
|
||||
this.floating = false
|
||||
|
||||
let {dom, update} = renderGrouped(this.editorView, this.options.content)
|
||||
this.contentUpdate = update
|
||||
this.menu.appendChild(dom)
|
||||
this.update()
|
||||
|
||||
if (options.floating && !isIOS()) {
|
||||
this.updateFloat()
|
||||
let potentialScrollers = getAllWrapping(this.wrapper)
|
||||
this.scrollFunc = (e) => {
|
||||
let root = this.editorView.root
|
||||
if (!(root.body || root).contains(this.wrapper)) {
|
||||
potentialScrollers.forEach(el => el.removeEventListener("scroll", this.scrollFunc))
|
||||
} else {
|
||||
this.updateFloat(e.target.getBoundingClientRect && e.target)
|
||||
}
|
||||
}
|
||||
potentialScrollers.forEach(el => el.addEventListener('scroll', this.scrollFunc))
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
this.contentUpdate(this.editorView.state)
|
||||
|
||||
if (this.floating) {
|
||||
this.updateScrollCursor()
|
||||
} else {
|
||||
if (this.menu.offsetWidth != this.widthForMaxHeight) {
|
||||
this.widthForMaxHeight = this.menu.offsetWidth
|
||||
this.maxHeight = 0
|
||||
}
|
||||
if (this.menu.offsetHeight > this.maxHeight) {
|
||||
this.maxHeight = this.menu.offsetHeight
|
||||
this.menu.style.minHeight = this.maxHeight + "px"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateScrollCursor() {
|
||||
let selection = this.editorView.root.getSelection()
|
||||
if (!selection.focusNode) return
|
||||
let rects = selection.getRangeAt(0).getClientRects()
|
||||
let selRect = rects[selectionIsInverted(selection) ? 0 : rects.length - 1]
|
||||
if (!selRect) return
|
||||
let menuRect = this.menu.getBoundingClientRect()
|
||||
if (selRect.top < menuRect.bottom && selRect.bottom > menuRect.top) {
|
||||
let scrollable = findWrappingScrollable(this.wrapper)
|
||||
if (scrollable) scrollable.scrollTop -= (menuRect.bottom - selRect.top)
|
||||
}
|
||||
}
|
||||
|
||||
updateFloat(scrollAncestor) {
|
||||
let parent = this.wrapper, editorRect = parent.getBoundingClientRect(),
|
||||
top = scrollAncestor ? Math.max(0, scrollAncestor.getBoundingClientRect().top) : 0
|
||||
|
||||
if (this.floating) {
|
||||
if (editorRect.top >= top || editorRect.bottom < this.menu.offsetHeight + 10) {
|
||||
this.floating = false
|
||||
this.menu.style.position = this.menu.style.left = this.menu.style.top = this.menu.style.width = ""
|
||||
this.menu.style.display = ""
|
||||
this.spacer.parentNode.removeChild(this.spacer)
|
||||
this.spacer = null
|
||||
} else {
|
||||
let border = (parent.offsetWidth - parent.clientWidth) / 2
|
||||
this.menu.style.left = (editorRect.left + border) + "px"
|
||||
this.menu.style.display = (editorRect.top > window.innerHeight ? "none" : "")
|
||||
if (scrollAncestor) this.menu.style.top = top + "px"
|
||||
}
|
||||
} else {
|
||||
if (editorRect.top < top && editorRect.bottom >= this.menu.offsetHeight + 10) {
|
||||
this.floating = true
|
||||
let menuRect = this.menu.getBoundingClientRect()
|
||||
this.menu.style.left = menuRect.left + "px"
|
||||
this.menu.style.width = menuRect.width + "px"
|
||||
if (scrollAncestor) this.menu.style.top = top + "px"
|
||||
this.menu.style.position = "fixed"
|
||||
this.spacer = crel("div", {class: prefix + "-spacer", style: `height: ${menuRect.height}px`})
|
||||
parent.insertBefore(this.spacer, this.menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.wrapper.parentNode)
|
||||
this.wrapper.parentNode.replaceChild(this.editorView.dom, this.wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
// Not precise, but close enough
|
||||
function selectionIsInverted(selection) {
|
||||
if (selection.anchorNode == selection.focusNode) return selection.anchorOffset > selection.focusOffset
|
||||
return selection.anchorNode.compareDocumentPosition(selection.focusNode) == Node.DOCUMENT_POSITION_FOLLOWING
|
||||
}
|
||||
|
||||
function findWrappingScrollable(node) {
|
||||
for (let cur = node.parentNode; cur; cur = cur.parentNode)
|
||||
if (cur.scrollHeight > cur.clientHeight) return cur
|
||||
}
|
||||
|
||||
function getAllWrapping(node) {
|
||||
let res = [window]
|
||||
for (let cur = node.parentNode; cur; cur = cur.parentNode)
|
||||
res.push(cur)
|
||||
return res
|
||||
}
|
||||
26
resources/js/editor/node-views/IframeView.js
Normal file
26
resources/js/editor/node-views/IframeView.js
Normal file
@@ -0,0 +1,26 @@
|
||||
class IframeView {
|
||||
/**
|
||||
* @param {PmNode} node
|
||||
* @param {PmView} view
|
||||
* @param {(function(): number)} getPos
|
||||
*/
|
||||
constructor(node, view, getPos) {
|
||||
this.dom = document.createElement('div');
|
||||
this.dom.classList.add('ProseMirror-iframewrap');
|
||||
|
||||
this.iframe = document.createElement("iframe");
|
||||
for (const [key, value] of Object.entries(node.attrs)) {
|
||||
if (value) {
|
||||
this.iframe.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
this.dom.appendChild(this.iframe);
|
||||
}
|
||||
|
||||
stopEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default IframeView;
|
||||
197
resources/js/editor/node-views/ImageView.js
Normal file
197
resources/js/editor/node-views/ImageView.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import {positionHandlesAtCorners, removeHandles, renderHandlesAtCorners} from "./node-view-utils";
|
||||
import {NodeSelection} from "prosemirror-state";
|
||||
|
||||
class ImageView {
|
||||
/**
|
||||
* @param {PmNode} node
|
||||
* @param {PmView} view
|
||||
* @param {(function(): number)} getPos
|
||||
*/
|
||||
constructor(node, view, getPos) {
|
||||
this.dom = document.createElement('div');
|
||||
this.dom.classList.add('ProseMirror-imagewrap');
|
||||
|
||||
this.image = document.createElement("img");
|
||||
this.image.src = node.attrs.src;
|
||||
this.image.alt = node.attrs.alt;
|
||||
if (node.attrs.width) {
|
||||
this.image.width = node.attrs.width;
|
||||
}
|
||||
if (node.attrs.height) {
|
||||
this.image.height = node.attrs.height;
|
||||
}
|
||||
|
||||
this.dom.appendChild(this.image);
|
||||
|
||||
this.handles = [];
|
||||
this.handleDragStartInfo = null;
|
||||
this.handleDragMoveDimensions = null;
|
||||
this.removeHandlesListener = this.removeHandlesListener.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
this.handleMouseUp = this.handleMouseUp.bind(this);
|
||||
this.handleMouseDown = this.handleMouseDown.bind(this);
|
||||
|
||||
this.dom.addEventListener("click", event => {
|
||||
this.showHandles();
|
||||
});
|
||||
|
||||
// Show handles if selected
|
||||
if (view.state.selection.node === node) {
|
||||
window.setTimeout(() => {
|
||||
this.showHandles();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
this.updateImageDimensions = function (width, height) {
|
||||
const attrs = Object.assign({}, node.attrs, {width, height});
|
||||
let tr = view.state.tr;
|
||||
const position = getPos();
|
||||
tr = tr.setNodeMarkup(position, null, attrs)
|
||||
tr = tr.setSelection(NodeSelection.create(tr.doc, position));
|
||||
view.dispatch(tr);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
showHandles() {
|
||||
if (this.handles.length === 0) {
|
||||
this.image.dataset.showHandles = 'true';
|
||||
window.addEventListener('click', this.removeHandlesListener);
|
||||
this.handles = renderHandlesAtCorners(this.image);
|
||||
for (const handle of this.handles) {
|
||||
handle.addEventListener('mousedown', this.handleMouseDown);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeHandlesListener(event) {
|
||||
if (!this.dom.contains(event.target)) {
|
||||
this.removeHandles();
|
||||
this.handles = [];
|
||||
}
|
||||
}
|
||||
|
||||
removeHandles() {
|
||||
removeHandles(this.handles);
|
||||
window.removeEventListener('click', this.removeHandlesListener);
|
||||
delete this.image.dataset.showHandles;
|
||||
}
|
||||
|
||||
stopEvent() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
handleMouseDown(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const imageBounds = this.image.getBoundingClientRect();
|
||||
const handle = event.target;
|
||||
this.handleDragStartInfo = {
|
||||
x: event.screenX,
|
||||
y: event.screenY,
|
||||
ratio: imageBounds.width / imageBounds.height,
|
||||
bounds: imageBounds,
|
||||
handleX: handle.dataset.x,
|
||||
handleY: handle.dataset.y,
|
||||
};
|
||||
|
||||
this.createDragDummy(imageBounds);
|
||||
this.dom.appendChild(this.dragDummy);
|
||||
|
||||
window.addEventListener('mousemove', this.handleMouseMove);
|
||||
window.addEventListener('mouseup', this.handleMouseUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DOMRect} bounds
|
||||
*/
|
||||
createDragDummy(bounds) {
|
||||
this.dragDummy = this.image.cloneNode();
|
||||
this.dragDummy.style.opacity = '0.5';
|
||||
this.dragDummy.classList.add('ProseMirror-dragdummy');
|
||||
this.dragDummy.style.width = bounds.width + 'px';
|
||||
this.dragDummy.style.height = bounds.height + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
handleMouseUp(event) {
|
||||
if (this.handleDragMoveDimensions) {
|
||||
const {width, height} = this.handleDragMoveDimensions;
|
||||
this.updateImageDimensions(String(width), String(height));
|
||||
}
|
||||
|
||||
window.removeEventListener('mousemove', this.handleMouseMove);
|
||||
window.removeEventListener('mouseup', this.handleMouseUp);
|
||||
this.handleDragStartInfo = null;
|
||||
this.handleDragMoveDimensions = null;
|
||||
this.dragDummy.remove();
|
||||
positionHandlesAtCorners(this.image, this.handles);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
handleMouseMove(event) {
|
||||
const originalBounds = this.handleDragStartInfo.bounds;
|
||||
|
||||
// Calculate change in x & y, flip amounts depending on handle
|
||||
let xChange = event.screenX - this.handleDragStartInfo.x;
|
||||
if (this.handleDragStartInfo.handleX === 'left') {
|
||||
xChange = -xChange;
|
||||
}
|
||||
let yChange = event.screenY - this.handleDragStartInfo.y;
|
||||
if (this.handleDragStartInfo.handleY === 'top') {
|
||||
yChange = -yChange;
|
||||
}
|
||||
|
||||
// Prevent images going too small or into negative bounds
|
||||
if (originalBounds.width + xChange < 10) {
|
||||
xChange = -originalBounds.width + 10;
|
||||
}
|
||||
if (originalBounds.height + yChange < 10) {
|
||||
yChange = -originalBounds.height + 10;
|
||||
}
|
||||
|
||||
// Choose the larger dimension change and align the other to keep
|
||||
// image aspect ratio, aligning growth/reduction direction
|
||||
if (Math.abs(xChange) > Math.abs(yChange)) {
|
||||
yChange = Math.floor(xChange * this.handleDragStartInfo.ratio);
|
||||
if (yChange * xChange < 0) {
|
||||
yChange = -yChange;
|
||||
}
|
||||
} else {
|
||||
xChange = Math.floor(yChange / this.handleDragStartInfo.ratio);
|
||||
if (xChange * yChange < 0) {
|
||||
xChange = -xChange;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate our new sizes
|
||||
const newWidth = originalBounds.width + xChange;
|
||||
const newHeight = originalBounds.height + yChange;
|
||||
|
||||
// Apply the sizes and positioning to our ghost dummy
|
||||
this.dragDummy.style.width = `${newWidth}px`;
|
||||
if (this.handleDragStartInfo.handleX === 'left') {
|
||||
this.dragDummy.style.left = `${-xChange}px`;
|
||||
}
|
||||
this.dragDummy.style.height = `${newHeight}px`;
|
||||
if (this.handleDragStartInfo.handleY === 'top') {
|
||||
this.dragDummy.style.top = `${-yChange}px`;
|
||||
}
|
||||
|
||||
// Update corners and track dimension changes for later application
|
||||
positionHandlesAtCorners(this.dragDummy, this.handles);
|
||||
this.handleDragMoveDimensions = {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageView;
|
||||
21
resources/js/editor/node-views/TableView.js
Normal file
21
resources/js/editor/node-views/TableView.js
Normal file
@@ -0,0 +1,21 @@
|
||||
class TableView {
|
||||
/**
|
||||
* @param {PmNode} node
|
||||
* @param {PmView} view
|
||||
* @param {(function(): number)} getPos
|
||||
*/
|
||||
constructor(node, view, getPos) {
|
||||
this.dom = document.createElement("div")
|
||||
this.dom.className = "ProseMirror-tableWrapper"
|
||||
this.table = this.dom.appendChild(document.createElement("table"));
|
||||
this.table.setAttribute('style', node.attrs.style);
|
||||
this.colgroup = this.table.appendChild(document.createElement("colgroup"));
|
||||
this.contentDOM = this.table.appendChild(document.createElement("tbody"));
|
||||
}
|
||||
|
||||
ignoreMutation(record) {
|
||||
return record.type == "attributes" && (record.target == this.table || this.colgroup.contains(record.target))
|
||||
}
|
||||
}
|
||||
|
||||
export default TableView;
|
||||
11
resources/js/editor/node-views/index.js
Normal file
11
resources/js/editor/node-views/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import ImageView from "./ImageView";
|
||||
import IframeView from "./IframeView";
|
||||
import TableView from "./TableView";
|
||||
|
||||
const views = {
|
||||
image: (node, view, getPos) => new ImageView(node, view, getPos),
|
||||
iframe: (node, view, getPos) => new IframeView(node, view, getPos),
|
||||
table: (node, view, getPos) => new TableView(node, view, getPos),
|
||||
};
|
||||
|
||||
export default views;
|
||||
58
resources/js/editor/node-views/node-view-utils.js
Normal file
58
resources/js/editor/node-views/node-view-utils.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import crel from "crelt";
|
||||
|
||||
/**
|
||||
* Render grab handles at the corners of the given element.
|
||||
* @param {Element} elem
|
||||
* @return {Element[]}
|
||||
*/
|
||||
export function renderHandlesAtCorners(elem) {
|
||||
const handles = [];
|
||||
const baseClass = 'ProseMirror-grabhandle';
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const y = (i < 2) ? 'top' : 'bottom';
|
||||
const x = (i === 0 || i === 3) ? 'left' : 'right';
|
||||
const handle = crel('div', {
|
||||
class: `${baseClass} ${baseClass}-${x}-${y}`,
|
||||
});
|
||||
handle.dataset.y = y;
|
||||
handle.dataset.x = x;
|
||||
handles.push(handle);
|
||||
elem.parentNode.appendChild(handle);
|
||||
}
|
||||
|
||||
positionHandlesAtCorners(elem, handles);
|
||||
return handles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element[]} handles
|
||||
*/
|
||||
export function removeHandles(handles) {
|
||||
for (const handle of handles) {
|
||||
handle.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Element} element
|
||||
* @param {[Element, Element, Element, Element]}handles
|
||||
*/
|
||||
export function positionHandlesAtCorners(element, handles) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const parentBounds = element.parentElement.getBoundingClientRect();
|
||||
const positions = [
|
||||
{x: bounds.left - parentBounds.left, y: bounds.top - parentBounds.top},
|
||||
{x: bounds.right - parentBounds.left, y: bounds.top - parentBounds.top},
|
||||
{x: bounds.right - parentBounds.left, y: bounds.bottom - parentBounds.top},
|
||||
{x: bounds.left - parentBounds.left, y: bounds.bottom - parentBounds.top},
|
||||
];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const {x, y} = positions[i];
|
||||
const handle = handles[i];
|
||||
handle.style.left = (x - 6) + 'px';
|
||||
handle.style.top = (y - 6) + 'px';
|
||||
}
|
||||
}
|
||||
288
resources/js/editor/plugins/table-resizing.js
Normal file
288
resources/js/editor/plugins/table-resizing.js
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* This file originates from https://github.com/ProseMirror/prosemirror-tables
|
||||
* and is hence subject to the MIT license found here:
|
||||
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
|
||||
* @copyright Marijn Haverbeke and others
|
||||
*/
|
||||
|
||||
import {Plugin, PluginKey} from "prosemirror-state"
|
||||
import {Decoration, DecorationSet} from "prosemirror-view"
|
||||
import {
|
||||
cellAround,
|
||||
pointsAtCell,
|
||||
setAttr,
|
||||
TableMap,
|
||||
} from "prosemirror-tables";
|
||||
|
||||
export const key = new PluginKey("tableColumnResizing")
|
||||
|
||||
export function columnResizing(options = {}) {
|
||||
const {
|
||||
handleWidth, cellMinWidth, lastColumnResizable
|
||||
} = Object.assign({
|
||||
handleWidth: 5,
|
||||
cellMinWidth: 25,
|
||||
lastColumnResizable: true
|
||||
}, options);
|
||||
|
||||
let plugin = new Plugin({
|
||||
key,
|
||||
state: {
|
||||
init(_, state) {
|
||||
return new ResizeState(-1, false)
|
||||
},
|
||||
apply(tr, prev) {
|
||||
return prev.apply(tr)
|
||||
}
|
||||
},
|
||||
props: {
|
||||
attributes(state) {
|
||||
let pluginState = key.getState(state)
|
||||
return pluginState.activeHandle > -1 ? {class: "resize-cursor"} : null
|
||||
},
|
||||
|
||||
handleDOMEvents: {
|
||||
mousemove(view, event) {
|
||||
handleMouseMove(view, event, handleWidth, cellMinWidth, lastColumnResizable)
|
||||
},
|
||||
mouseleave(view) {
|
||||
handleMouseLeave(view)
|
||||
},
|
||||
mousedown(view, event) {
|
||||
handleMouseDown(view, event, cellMinWidth)
|
||||
}
|
||||
},
|
||||
|
||||
decorations(state) {
|
||||
let pluginState = key.getState(state)
|
||||
if (pluginState.activeHandle > -1) return handleDecorations(state, pluginState.activeHandle)
|
||||
},
|
||||
|
||||
nodeViews: {}
|
||||
}
|
||||
})
|
||||
return plugin
|
||||
}
|
||||
|
||||
class ResizeState {
|
||||
constructor(activeHandle, dragging) {
|
||||
this.activeHandle = activeHandle
|
||||
this.dragging = dragging
|
||||
}
|
||||
|
||||
apply(tr) {
|
||||
let state = this, action = tr.getMeta(key)
|
||||
if (action && action.setHandle != null)
|
||||
return new ResizeState(action.setHandle, null)
|
||||
if (action && action.setDragging !== undefined)
|
||||
return new ResizeState(state.activeHandle, action.setDragging)
|
||||
if (state.activeHandle > -1 && tr.docChanged) {
|
||||
let handle = tr.mapping.map(state.activeHandle, -1)
|
||||
if (!pointsAtCell(tr.doc.resolve(handle))) handle = null
|
||||
state = new ResizeState(handle, state.dragging)
|
||||
}
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(view, event, handleWidth, cellMinWidth, lastColumnResizable) {
|
||||
let pluginState = key.getState(view.state)
|
||||
|
||||
if (!pluginState.dragging) {
|
||||
let target = domCellAround(event.target), cell = -1
|
||||
if (target) {
|
||||
let {left, right} = target.getBoundingClientRect()
|
||||
if (event.clientX - left <= handleWidth)
|
||||
cell = edgeCell(view, event, "left")
|
||||
else if (right - event.clientX <= handleWidth)
|
||||
cell = edgeCell(view, event, "right")
|
||||
}
|
||||
|
||||
if (cell != pluginState.activeHandle) {
|
||||
if (!lastColumnResizable && cell !== -1) {
|
||||
let $cell = view.state.doc.resolve(cell)
|
||||
let table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1)
|
||||
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1
|
||||
|
||||
if (col == map.width - 1) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updateHandle(view, cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave(view) {
|
||||
let pluginState = key.getState(view.state)
|
||||
if (pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1)
|
||||
}
|
||||
|
||||
function handleMouseDown(view, event, cellMinWidth) {
|
||||
let pluginState = key.getState(view.state)
|
||||
if (pluginState.activeHandle == -1 || pluginState.dragging) return false
|
||||
|
||||
let cell = view.state.doc.nodeAt(pluginState.activeHandle)
|
||||
let width = currentColWidth(view, pluginState.activeHandle, cell.attrs)
|
||||
view.dispatch(view.state.tr.setMeta(key, {setDragging: {startX: event.clientX, startWidth: width}}))
|
||||
|
||||
function finish(event) {
|
||||
window.removeEventListener("mouseup", finish)
|
||||
window.removeEventListener("mousemove", move)
|
||||
let pluginState = key.getState(view.state)
|
||||
if (pluginState.dragging) {
|
||||
updateColumnWidth(view, pluginState.activeHandle, draggedWidth(pluginState.dragging, event, cellMinWidth))
|
||||
view.dispatch(view.state.tr.setMeta(key, {setDragging: null}))
|
||||
}
|
||||
}
|
||||
|
||||
function move(event) {
|
||||
if (!event.which) return finish(event)
|
||||
let pluginState = key.getState(view.state)
|
||||
let dragged = draggedWidth(pluginState.dragging, event, cellMinWidth)
|
||||
displayColumnWidth(view, pluginState.activeHandle, dragged, cellMinWidth)
|
||||
}
|
||||
|
||||
window.addEventListener("mouseup", finish)
|
||||
window.addEventListener("mousemove", move)
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
function currentColWidth(view, cellPos, {colspan, colwidth}) {
|
||||
let width = colwidth && colwidth[colwidth.length - 1]
|
||||
if (width) return width
|
||||
let dom = view.domAtPos(cellPos)
|
||||
let node = dom.node.childNodes[dom.offset]
|
||||
let domWidth = node.offsetWidth, parts = colspan
|
||||
if (colwidth) for (let i = 0; i < colspan; i++) if (colwidth[i]) {
|
||||
domWidth -= colwidth[i]
|
||||
parts--
|
||||
}
|
||||
return domWidth / parts
|
||||
}
|
||||
|
||||
function domCellAround(target) {
|
||||
while (target && target.nodeName != "TD" && target.nodeName != "TH")
|
||||
target = target.classList.contains("ProseMirror") ? null : target.parentNode
|
||||
return target
|
||||
}
|
||||
|
||||
function edgeCell(view, event, side) {
|
||||
let found = view.posAtCoords({left: event.clientX, top: event.clientY})
|
||||
if (!found) return -1
|
||||
let {pos} = found
|
||||
let $cell = cellAround(view.state.doc.resolve(pos))
|
||||
if (!$cell) return -1
|
||||
if (side == "right") return $cell.pos
|
||||
let map = TableMap.get($cell.node(-1)), start = $cell.start(-1)
|
||||
let index = map.map.indexOf($cell.pos - start)
|
||||
return index % map.width == 0 ? -1 : start + map.map[index - 1]
|
||||
}
|
||||
|
||||
function draggedWidth(dragging, event, cellMinWidth) {
|
||||
let offset = event.clientX - dragging.startX
|
||||
return Math.max(cellMinWidth, dragging.startWidth + offset)
|
||||
}
|
||||
|
||||
function updateHandle(view, value) {
|
||||
view.dispatch(view.state.tr.setMeta(key, {setHandle: value}))
|
||||
}
|
||||
|
||||
function updateColumnWidth(view, cell, width) {
|
||||
let $cell = view.state.doc.resolve(cell);
|
||||
let table = $cell.node(-1);
|
||||
let map = TableMap.get(table);
|
||||
let start = $cell.start(-1);
|
||||
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
|
||||
let tr = view.state.tr;
|
||||
|
||||
for (let row = 0; row < map.height; row++) {
|
||||
let mapIndex = row * map.width + col;
|
||||
// Rowspanning cell that has already been handled
|
||||
if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) continue
|
||||
let pos = map.map[mapIndex]
|
||||
let {attrs} = table.nodeAt(pos);
|
||||
const newWidth = (attrs.colspan * width) + 'px';
|
||||
|
||||
tr.setNodeMarkup(start + pos, null, setAttr(attrs, "width", newWidth));
|
||||
}
|
||||
|
||||
if (tr.docChanged) view.dispatch(tr)
|
||||
}
|
||||
|
||||
function displayColumnWidth(view, cell, width, cellMinWidth) {
|
||||
const $cell = view.state.doc.resolve(cell)
|
||||
const table = $cell.node(-1);
|
||||
const start = $cell.start(-1);
|
||||
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1
|
||||
let dom = view.domAtPos($cell.start(-1)).node
|
||||
while (dom.nodeName !== "TABLE") {
|
||||
dom = dom.parentNode
|
||||
}
|
||||
updateColumnsOnResize(view, table, dom, cellMinWidth, col, width)
|
||||
}
|
||||
|
||||
|
||||
function updateColumnsOnResize(view, tableNode, tableDom, cellMinWidth, overrideCol, overrideValue) {
|
||||
console.log({tableNode, tableDom, cellMinWidth, overrideCol, overrideValue});
|
||||
let totalWidth = 0;
|
||||
let fixedWidth = true;
|
||||
const rows = tableDom.querySelectorAll('tr');
|
||||
|
||||
for (let y = 0; y < rows.length; y++) {
|
||||
const row = rows[y];
|
||||
const cell = row.children[overrideCol];
|
||||
cell.style.width = `${overrideValue}px`;
|
||||
if (y === 0) {
|
||||
for (let x = 0; x < row.children.length; x++) {
|
||||
const cell = row.children[x];
|
||||
if (cell.style.width) {
|
||||
const width = Number(cell.style.width.replace('px', ''));
|
||||
totalWidth += width || cellMinWidth;
|
||||
} else {
|
||||
fixedWidth = false;
|
||||
totalWidth += cellMinWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(totalWidth);
|
||||
if (fixedWidth) {
|
||||
tableDom.style.width = totalWidth + "px"
|
||||
tableDom.style.minWidth = ""
|
||||
} else {
|
||||
tableDom.style.width = ""
|
||||
tableDom.style.minWidth = totalWidth + "px"
|
||||
}
|
||||
}
|
||||
|
||||
function zeroes(n) {
|
||||
let result = []
|
||||
for (let i = 0; i < n; i++) result.push(0)
|
||||
return result
|
||||
}
|
||||
|
||||
function handleDecorations(state, cell) {
|
||||
let decorations = []
|
||||
let $cell = state.doc.resolve(cell)
|
||||
let table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1)
|
||||
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan
|
||||
for (let row = 0; row < map.height; row++) {
|
||||
let index = col + row * map.width - 1
|
||||
// For positions that are have either a different cell or the end
|
||||
// of the table to their right, and either the top of the table or
|
||||
// a different cell above them, add a decoration
|
||||
if ((col == map.width || map.map[index] != map.map[index + 1]) &&
|
||||
(row == 0 || map.map[index - 1] != map.map[index - 1 - map.width])) {
|
||||
let cellPos = map.map[index]
|
||||
let pos = start + cellPos + table.nodeAt(cellPos).nodeSize - 1
|
||||
let dom = document.createElement("div")
|
||||
dom.className = "column-resize-handle"
|
||||
decorations.push(Decoration.widget(pos, dom))
|
||||
}
|
||||
}
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
}
|
||||
131
resources/js/editor/schema-marks.js
Normal file
131
resources/js/editor/schema-marks.js
Normal file
@@ -0,0 +1,131 @@
|
||||
const link = {
|
||||
attrs: {
|
||||
href: {},
|
||||
title: {default: null},
|
||||
target: {default: null}
|
||||
},
|
||||
inclusive: false,
|
||||
parseDOM: [{
|
||||
tag: "a[href]", getAttrs: function getAttrs(dom) {
|
||||
return {
|
||||
href: dom.getAttribute("href"),
|
||||
title: dom.getAttribute("title"),
|
||||
target: dom.getAttribute("target"),
|
||||
}
|
||||
}
|
||||
}],
|
||||
toDOM: function toDOM(node) {
|
||||
const ref = node.attrs;
|
||||
const href = ref.href;
|
||||
const title = ref.title;
|
||||
const target = ref.target;
|
||||
return ["a", {href, title, target}, 0]
|
||||
}
|
||||
};
|
||||
|
||||
const em = {
|
||||
parseDOM: [{tag: "i"}, {tag: "em"}, {style: "font-style=italic"}],
|
||||
toDOM() {
|
||||
return ["em", 0]
|
||||
}
|
||||
};
|
||||
|
||||
const strong = {
|
||||
parseDOM: [{tag: "strong"},
|
||||
// This works around a Google Docs misbehavior where
|
||||
// pasted content will be inexplicably wrapped in `<b>`
|
||||
// tags with a font-weight normal.
|
||||
{
|
||||
tag: "b", getAttrs: function (node) {
|
||||
return node.style.fontWeight != "normal" && null;
|
||||
}
|
||||
},
|
||||
{
|
||||
style: "font-weight", getAttrs: function (value) {
|
||||
return /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null;
|
||||
}
|
||||
}],
|
||||
toDOM() {
|
||||
return ["strong", 0]
|
||||
}
|
||||
};
|
||||
|
||||
const code = {
|
||||
parseDOM: [{tag: "code"}],
|
||||
toDOM() {
|
||||
return ["code", 0]
|
||||
}
|
||||
};
|
||||
|
||||
const underline = {
|
||||
parseDOM: [{tag: "u"}, {style: "text-decoration=underline"}],
|
||||
toDOM() {
|
||||
return ["span", {style: "text-decoration: underline;"}, 0];
|
||||
}
|
||||
};
|
||||
|
||||
const strike = {
|
||||
parseDOM: [{tag: "s"}, {tag: "strike"}, {style: "text-decoration=line-through"}],
|
||||
toDOM() {
|
||||
return ["span", {style: "text-decoration: line-through;"}, 0];
|
||||
}
|
||||
};
|
||||
|
||||
const superscript = {
|
||||
parseDOM: [{tag: "sup"}],
|
||||
toDOM() {
|
||||
return ["sup", 0];
|
||||
}
|
||||
};
|
||||
|
||||
const subscript = {
|
||||
parseDOM: [{tag: "sub"}],
|
||||
toDOM() {
|
||||
return ["sub", 0];
|
||||
}
|
||||
};
|
||||
|
||||
const text_color = {
|
||||
attrs: {
|
||||
color: {},
|
||||
},
|
||||
parseDOM: [{
|
||||
style: 'color',
|
||||
getAttrs(color) {
|
||||
return {color}
|
||||
}
|
||||
}],
|
||||
toDOM(node) {
|
||||
return ['span', {style: `color: ${node.attrs.color};`}, 0];
|
||||
}
|
||||
};
|
||||
|
||||
const background_color = {
|
||||
attrs: {
|
||||
color: {},
|
||||
},
|
||||
parseDOM: [{
|
||||
style: 'background-color',
|
||||
getAttrs(color) {
|
||||
return {color}
|
||||
}
|
||||
}],
|
||||
toDOM(node) {
|
||||
return ['span', {style: `background-color: ${node.attrs.color};`}, 0];
|
||||
}
|
||||
};
|
||||
|
||||
const marks = {
|
||||
link,
|
||||
em,
|
||||
strong,
|
||||
code,
|
||||
underline,
|
||||
strike,
|
||||
superscript,
|
||||
subscript,
|
||||
text_color,
|
||||
background_color,
|
||||
};
|
||||
|
||||
export default marks;
|
||||
383
resources/js/editor/schema-nodes.js
Normal file
383
resources/js/editor/schema-nodes.js
Normal file
@@ -0,0 +1,383 @@
|
||||
import {orderedList, bulletList, listItem} from "prosemirror-schema-list";
|
||||
import {Fragment} from "prosemirror-model";
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} node
|
||||
* @return {string|null}
|
||||
*/
|
||||
function getAlignAttrFromDomNode(node) {
|
||||
const classList = node.classList;
|
||||
const styles = node.style || {};
|
||||
const alignments = ['right', 'left', 'center', 'justify'];
|
||||
for (const alignment of alignments) {
|
||||
if (classList.contains('align-' + alignment) || styles.textAlign === alignment) {
|
||||
return alignment;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param node
|
||||
* @param {Object} attrs
|
||||
* @return {Object}
|
||||
*/
|
||||
function addAlignmentAttr(node, attrs) {
|
||||
const positions = ['right', 'left', 'center', 'justify'];
|
||||
for (const position of positions) {
|
||||
if (node.attrs.align === position) {
|
||||
return addClassToAttrs('align-' + position, attrs);
|
||||
}
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function getAttrsParserForAlignment(node) {
|
||||
return {
|
||||
align: getAlignAttrFromDomNode(node),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} className
|
||||
* @param {Object} attrs
|
||||
* @return {Object}
|
||||
*/
|
||||
function addClassToAttrs(className, attrs) {
|
||||
return Object.assign({}, attrs, {
|
||||
class: attrs.class ? attrs.class + ' ' + className : className,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String[]} attrNames
|
||||
* @return {function(Element): {}}
|
||||
*/
|
||||
function domAttrsToAttrsParser(attrNames) {
|
||||
return function (node) {
|
||||
const attrs = {};
|
||||
for (const attr of attrNames) {
|
||||
attrs[attr] = node.hasAttribute(attr) ? node.getAttribute(attr) : null;
|
||||
}
|
||||
return attrs;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmNode} node
|
||||
* @param {String[]} attrNames
|
||||
*/
|
||||
function extractAttrsForDom(node, attrNames) {
|
||||
const domAttrs = {};
|
||||
for (const attr of attrNames) {
|
||||
if (node.attrs[attr]) {
|
||||
domAttrs[attr] = node.attrs[attr];
|
||||
}
|
||||
}
|
||||
return domAttrs;
|
||||
}
|
||||
|
||||
const doc = {
|
||||
content: "block+",
|
||||
};
|
||||
|
||||
const paragraph = {
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "p",
|
||||
getAttrs: getAttrsParserForAlignment,
|
||||
}
|
||||
],
|
||||
attrs: {
|
||||
align: {
|
||||
default: null,
|
||||
}
|
||||
},
|
||||
toDOM(node) {
|
||||
return ["p", addAlignmentAttr(node, {}), 0];
|
||||
}
|
||||
};
|
||||
|
||||
const blockquote = {
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "blockquote", getAttrs: getAttrsParserForAlignment}],
|
||||
attrs: {
|
||||
align: {
|
||||
default: null,
|
||||
}
|
||||
},
|
||||
toDOM(node) {
|
||||
return ["blockquote", addAlignmentAttr(node, {}), 0];
|
||||
}
|
||||
};
|
||||
|
||||
const horizontal_rule = {
|
||||
group: "block",
|
||||
parseDOM: [{tag: "hr"}],
|
||||
toDOM() {
|
||||
return ["hr"];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const headingParseGetAttrs = (level) => {
|
||||
return function (node) {
|
||||
return {level, align: getAlignAttrFromDomNode(node)};
|
||||
};
|
||||
};
|
||||
const heading = {
|
||||
attrs: {level: {default: 1}, align: {default: null}},
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [
|
||||
{tag: "h1", getAttrs: headingParseGetAttrs(1)},
|
||||
{tag: "h2", getAttrs: headingParseGetAttrs(2)},
|
||||
{tag: "h3", getAttrs: headingParseGetAttrs(3)},
|
||||
{tag: "h4", getAttrs: headingParseGetAttrs(4)},
|
||||
{tag: "h5", getAttrs: headingParseGetAttrs(5)},
|
||||
{tag: "h6", getAttrs: headingParseGetAttrs(6)},
|
||||
],
|
||||
toDOM(node) {
|
||||
return ["h" + node.attrs.level, addAlignmentAttr(node, {}), 0]
|
||||
}
|
||||
};
|
||||
|
||||
const code_block = {
|
||||
content: "text*",
|
||||
marks: "",
|
||||
group: "block",
|
||||
code: true,
|
||||
defining: true,
|
||||
parseDOM: [{tag: "pre", preserveWhitespace: "full"}],
|
||||
toDOM() {
|
||||
return ["pre", ["code", 0]];
|
||||
}
|
||||
};
|
||||
|
||||
const text = {
|
||||
group: "inline"
|
||||
};
|
||||
|
||||
const image = {
|
||||
inline: true,
|
||||
attrs: {
|
||||
src: {},
|
||||
alt: {default: null},
|
||||
title: {default: null},
|
||||
height: {default: null},
|
||||
width: {default: null},
|
||||
},
|
||||
group: "inline",
|
||||
draggable: true,
|
||||
parseDOM: [{
|
||||
tag: "img[src]", getAttrs: function getAttrs(dom) {
|
||||
return {
|
||||
src: dom.getAttribute("src"),
|
||||
title: dom.getAttribute("title"),
|
||||
alt: dom.getAttribute("alt"),
|
||||
height: dom.getAttribute("height"),
|
||||
width: dom.getAttribute("width"),
|
||||
}
|
||||
}
|
||||
}],
|
||||
toDOM: function toDOM(node) {
|
||||
const ref = node.attrs;
|
||||
const src = ref.src;
|
||||
const alt = ref.alt;
|
||||
const title = ref.title;
|
||||
const width = ref.width;
|
||||
const height = ref.height;
|
||||
return ["img", {src, alt, title, width, height}]
|
||||
}
|
||||
};
|
||||
|
||||
const iframe = {
|
||||
attrs: {
|
||||
src: {},
|
||||
height: {default: null},
|
||||
width: {default: null},
|
||||
title: {default: null},
|
||||
allow: {default: null},
|
||||
sandbox: {default: null},
|
||||
},
|
||||
group: "block",
|
||||
draggable: true,
|
||||
parseDOM: [{
|
||||
tag: "iframe",
|
||||
getAttrs: domAttrsToAttrsParser(["src", "width", "height", "title", "allow", "sandbox"]),
|
||||
}],
|
||||
toDOM(node) {
|
||||
const attrs = extractAttrsForDom(node, ["src", "width", "height", "title", "allow", "sandbox"])
|
||||
return ["iframe", attrs];
|
||||
}
|
||||
};
|
||||
|
||||
const hard_break = {
|
||||
inline: true,
|
||||
group: "inline",
|
||||
selectable: false,
|
||||
parseDOM: [{tag: "br"}],
|
||||
toDOM() {
|
||||
return ["br"];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const calloutParseGetAttrs = (type) => {
|
||||
return function (node) {
|
||||
return {type, align: getAlignAttrFromDomNode(node)};
|
||||
};
|
||||
};
|
||||
const callout = {
|
||||
attrs: {
|
||||
type: {default: 'info'},
|
||||
align: {default: null},
|
||||
},
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [
|
||||
{tag: 'p.callout.info', getAttrs: calloutParseGetAttrs('info'), priority: 75},
|
||||
{tag: 'p.callout.success', getAttrs: calloutParseGetAttrs('success'), priority: 75},
|
||||
{tag: 'p.callout.danger', getAttrs: calloutParseGetAttrs('danger'), priority: 75},
|
||||
{tag: 'p.callout.warning', getAttrs: calloutParseGetAttrs('warning'), priority: 75},
|
||||
{tag: 'p.callout', getAttrs: calloutParseGetAttrs('info'), priority: 75},
|
||||
],
|
||||
toDOM(node) {
|
||||
const type = node.attrs.type || 'info';
|
||||
return ['p', addAlignmentAttr(node, {class: 'callout ' + type}), 0];
|
||||
}
|
||||
};
|
||||
|
||||
const ordered_list = Object.assign({}, orderedList, {content: "list_item+", group: "block"});
|
||||
const bullet_list = Object.assign({}, bulletList, {content: "list_item+", group: "block"});
|
||||
const list_item = Object.assign({}, listItem, {content: 'paragraph block*'});
|
||||
|
||||
const table = {
|
||||
content: "table_row+",
|
||||
attrs: {
|
||||
style: {default: null},
|
||||
},
|
||||
tableRole: "table",
|
||||
isolating: true,
|
||||
group: "block",
|
||||
parseDOM: [{tag: "table", getAttrs: domAttrsToAttrsParser(['style'])}],
|
||||
toDOM(node) {
|
||||
return ["table", extractAttrsForDom(node, ['style']), ["tbody", 0]]
|
||||
}
|
||||
};
|
||||
|
||||
const table_row = {
|
||||
content: "(table_cell | table_header)*",
|
||||
tableRole: "row",
|
||||
parseDOM: [{tag: "tr"}],
|
||||
toDOM() { return ["tr", 0] }
|
||||
};
|
||||
|
||||
let cellAttrs = {
|
||||
colspan: {default: 1},
|
||||
rowspan: {default: 1},
|
||||
width: {default: null},
|
||||
height: {default: null},
|
||||
};
|
||||
|
||||
function getCellAttrs(dom) {
|
||||
return {
|
||||
colspan: Number(dom.getAttribute("colspan") || 1),
|
||||
rowspan: Number(dom.getAttribute("rowspan") || 1),
|
||||
width: dom.style.width || null,
|
||||
height: dom.style.height || null,
|
||||
};
|
||||
}
|
||||
|
||||
function setCellAttrs(node) {
|
||||
let attrs = {};
|
||||
|
||||
const styles = [];
|
||||
if (node.attrs.colspan != 1) attrs.colspan = node.attrs.colspan;
|
||||
if (node.attrs.rowspan != 1) attrs.rowspan = node.attrs.rowspan;
|
||||
if (node.attrs.width) styles.push(`width: ${node.attrs.width}`);
|
||||
if (node.attrs.height) styles.push(`height: ${node.attrs.height}`);
|
||||
if (styles) {
|
||||
attrs.style = styles.join(';');
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
const table_cell = {
|
||||
content: "block+",
|
||||
attrs: cellAttrs,
|
||||
tableRole: "cell",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "td", getAttrs: dom => getCellAttrs(dom)}],
|
||||
toDOM(node) { return ["td", setCellAttrs(node), 0] }
|
||||
};
|
||||
|
||||
const table_header = {
|
||||
content: "block+",
|
||||
attrs: cellAttrs,
|
||||
tableRole: "header_cell",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "th", getAttrs: dom => getCellAttrs(dom)}],
|
||||
toDOM(node) { return ["th", setCellAttrs(node), 0] }
|
||||
};
|
||||
|
||||
|
||||
const details = {
|
||||
content: "details_summary block*",
|
||||
isolating: true,
|
||||
group: "block",
|
||||
parseDOM: [{
|
||||
tag: "details",
|
||||
getAttrs(domNode) {
|
||||
return {}
|
||||
},
|
||||
}],
|
||||
toDOM(node) {
|
||||
return ["details", 0];
|
||||
}
|
||||
};
|
||||
|
||||
const details_summary = {
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
parseDOM: [{
|
||||
tag: "details summary",
|
||||
}],
|
||||
toDOM(node) {
|
||||
return ["summary", 0];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const nodes = {
|
||||
doc,
|
||||
paragraph,
|
||||
blockquote,
|
||||
horizontal_rule,
|
||||
heading,
|
||||
code_block,
|
||||
text,
|
||||
image,
|
||||
iframe,
|
||||
hard_break,
|
||||
callout,
|
||||
ordered_list,
|
||||
bullet_list,
|
||||
list_item,
|
||||
table,
|
||||
table_row,
|
||||
table_cell,
|
||||
table_header,
|
||||
details,
|
||||
details_summary,
|
||||
};
|
||||
|
||||
export default nodes;
|
||||
12
resources/js/editor/schema.js
Normal file
12
resources/js/editor/schema.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import {Schema} from "prosemirror-model";
|
||||
|
||||
import nodes from "./schema-nodes";
|
||||
import marks from "./schema-marks";
|
||||
|
||||
/** @var {PmSchema} schema */
|
||||
const schema = new Schema({
|
||||
nodes,
|
||||
marks,
|
||||
});
|
||||
|
||||
export default schema;
|
||||
106
resources/js/editor/types.js
Normal file
106
resources/js/editor/types.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @typedef {Object} PmEditorState
|
||||
* @property {PmNode} doc
|
||||
* @property {PmSelection} selection
|
||||
* @property {PmMark[]|null} storedMarks
|
||||
* @property {PmSchema} schema
|
||||
* @property {PmTransaction} tr
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmNode
|
||||
* @property {PmNodeType} type
|
||||
* @property {Object} attrs
|
||||
* @property {PmFragment} content
|
||||
* @property {PmMark[]} marks
|
||||
* @property {String|null} text
|
||||
* @property {Number} nodeSize
|
||||
* @property {Number} childCount
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmNodeType
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmMark
|
||||
* @property {PmMarkType} type
|
||||
* @property {Object} attrs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmMarkType
|
||||
* @property {String} name
|
||||
* @property {PmSchema} schema
|
||||
* @property {PmMarkSpec} spec
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmMarkSpec
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmSchema
|
||||
* @property {PmSchema} schema
|
||||
* @property {Object<PmNodeType>} nodes
|
||||
* @property {Object<PmMarkType>} marks
|
||||
* @property {PmNodeType} topNodeType
|
||||
* @property {Object} cached
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmSelection
|
||||
* @property {PmSelectionRange[]} ranges
|
||||
* @property {PmResolvedPos} $anchor
|
||||
* @property {PmResolvedPos} $head
|
||||
* @property {Number} anchor
|
||||
* @property {Number} head
|
||||
* @property {Number} from
|
||||
* @property {Number} to
|
||||
* @property {PmResolvedPos} $from
|
||||
* @property {PmResolvedPos} $to
|
||||
* @property {Boolean} empty
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmResolvedPos
|
||||
* @property {Number} pos
|
||||
* @property {Number} depth
|
||||
* @property {Number} parentOffset
|
||||
* @property {PmNode} parent
|
||||
* @property {PmNode} doc
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmSelectionRange
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmTransaction
|
||||
* @property {Number} time
|
||||
* @property {PmMark[]|null} storedMarks
|
||||
* @property {PmSelection} selection
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmFragment
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Function} PmCommandHandler
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Function} PmDispatchFunction
|
||||
* @param {PmTransaction} tr
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmView
|
||||
* @param {PmEditorState} state
|
||||
* @param {Element} dom
|
||||
* @param {Boolean} editable
|
||||
* @param {Boolean} composing
|
||||
*/
|
||||
131
resources/js/editor/util.js
Normal file
131
resources/js/editor/util.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import schema from "./schema";
|
||||
import {DOMParser, DOMSerializer} from "prosemirror-model";
|
||||
|
||||
/**
|
||||
* @param {String} html
|
||||
* @return {PmNode}
|
||||
*/
|
||||
export function htmlToDoc(html) {
|
||||
const renderDoc = document.implementation.createHTMLDocument();
|
||||
renderDoc.body.innerHTML = html;
|
||||
return DOMParser.fromSchema(schema).parse(renderDoc.body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmNode} doc
|
||||
* @return {string}
|
||||
*/
|
||||
export function docToHtml(doc) {
|
||||
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(doc.content);
|
||||
const renderDoc = document.implementation.createHTMLDocument();
|
||||
renderDoc.body.appendChild(fragment);
|
||||
return renderDoc.body.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @return {String}
|
||||
*/
|
||||
export function stateToHtml(state) {
|
||||
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(state.doc.content);
|
||||
const renderDoc = document.implementation.createHTMLDocument();
|
||||
renderDoc.body.appendChild(fragment);
|
||||
return renderDoc.body.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} object
|
||||
* @return {{}}
|
||||
*/
|
||||
export function nullifyEmptyValues(object) {
|
||||
const clean = {};
|
||||
for (const [key, value] of Object.entries(object)) {
|
||||
clean[key] = (value === "") ? null : value;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmSelection} selection
|
||||
* @param {PmMarkType} markType
|
||||
* @return {{from: Number, to: Number}}
|
||||
*/
|
||||
export function expandSelectionToMark(state, selection, markType) {
|
||||
let {from, to} = selection;
|
||||
const noRange = (from === to);
|
||||
if (noRange) {
|
||||
const markRange = markRangeAtPosition(state, markType, from);
|
||||
if (markRange.from !== -1) {
|
||||
from = markRange.from;
|
||||
to = markRange.to;
|
||||
}
|
||||
}
|
||||
return {from, to};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmMarkType} markType
|
||||
* @param {Number} pos
|
||||
* @return {{from: Number, to: Number}}
|
||||
*/
|
||||
export function markRangeAtPosition(state, markType, pos) {
|
||||
const $pos = state.doc.resolve(pos);
|
||||
|
||||
const {parent, parentOffset} = $pos;
|
||||
const start = parent.childAfter(parentOffset);
|
||||
if (!start.node) return {from: -1, to: -1};
|
||||
|
||||
const mark = start.node.marks.find((mark) => mark.type === markType);
|
||||
if (!mark) return {from: -1, to: -1};
|
||||
|
||||
let startIndex = $pos.index();
|
||||
let startPos = $pos.start() + start.offset;
|
||||
let endIndex = startIndex + 1;
|
||||
let endPos = startPos + start.node.nodeSize;
|
||||
while (startIndex > 0 && mark.isInSet(parent.child(startIndex - 1).marks)) {
|
||||
startIndex -= 1;
|
||||
startPos -= parent.child(startIndex).nodeSize;
|
||||
}
|
||||
while (endIndex < parent.childCount && mark.isInSet(parent.child(endIndex).marks)) {
|
||||
endPos += parent.child(endIndex).nodeSize;
|
||||
endIndex += 1;
|
||||
}
|
||||
return {from: startPos, to: endPos};
|
||||
}
|
||||
|
||||
/**
|
||||
* @class KeyedMultiStack
|
||||
* Holds many stacks, seperated via a key, with a simple
|
||||
* interface to pop and push values to the stacks.
|
||||
*/
|
||||
export class KeyedMultiStack {
|
||||
|
||||
constructor() {
|
||||
this.stack = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} key
|
||||
* @return {undefined|*}
|
||||
*/
|
||||
pop(key) {
|
||||
if (Array.isArray(this.stack[key])) {
|
||||
return this.stack[key].pop();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} key
|
||||
* @param {*} value
|
||||
*/
|
||||
push(key, value) {
|
||||
if (this.stack[key] === undefined) {
|
||||
this.stack[key] = [];
|
||||
}
|
||||
|
||||
this.stack[key].push(value);
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'عرض القائمة',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expand Header Menu',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Otvori meni u zaglavlju',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expand Header Menu',
|
||||
|
||||
@@ -74,8 +74,7 @@ return [
|
||||
'status' => 'Stav',
|
||||
'status_active' => 'Aktivní',
|
||||
'status_inactive' => 'Neaktivní',
|
||||
'never' => 'Nikdy',
|
||||
'none' => 'None',
|
||||
'never' => 'Never',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Rozbalit menu v záhlaví',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Udvid header menu',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Aktiv',
|
||||
'status_inactive' => 'Inaktiv',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Header-Menü erweitern',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Aktiv',
|
||||
'status_inactive' => 'Inaktiv',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Header-Menü erweitern',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expand Header Menu',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Activo',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Nunca',
|
||||
'none' => 'Ninguno',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expandir el Menú de la Cabecera',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
return [
|
||||
|
||||
// Pages
|
||||
'page_create' => 'creó la página',
|
||||
'page_create' => 'página creada',
|
||||
'page_create_notification' => 'Página creada correctamente',
|
||||
'page_update' => 'página actualizada',
|
||||
'page_update_notification' => 'Página actualizada correctamente',
|
||||
|
||||
@@ -73,9 +73,8 @@ return [
|
||||
'breadcrumb' => 'Miga de Pan',
|
||||
'status' => 'Estado',
|
||||
'status_active' => 'Activo',
|
||||
'status_inactive' => 'Inactivo',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Nunca',
|
||||
'none' => 'Ninguno',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expandir el Menú de Cabecera',
|
||||
|
||||
@@ -341,7 +341,7 @@ return [
|
||||
'copy_consider' => 'Por favor, tenga en cuenta lo siguiente al copiar el contenido.',
|
||||
'copy_consider_permissions' => 'Los ajustes de permisos personalizados no serán copiados.',
|
||||
'copy_consider_owner' => 'Usted se convertirá en el dueño de todo el contenido copiado.',
|
||||
'copy_consider_images' => 'Los archivos de imagen de la página no serán duplicados y las imágenes originales conservarán su relación con la página a la que fueron subidos originalmente.',
|
||||
'copy_consider_images' => 'Los archivos de imagen de de las páginas no serán duplicados y las imágenes originales conservarán su relación con la página a la que fueron subidos originalmente.',
|
||||
'copy_consider_attachments' => 'Los archivos adjuntos de la página no serán copiados.',
|
||||
'copy_consider_access' => 'Un cambio de ubicación, propietario o permisos puede resultar en que este contenido sea accesible para aquellos que anteriormente no tuvieran acceso.',
|
||||
];
|
||||
|
||||
@@ -236,26 +236,26 @@ return [
|
||||
|
||||
// Webhooks
|
||||
'webhooks' => 'Webhooks',
|
||||
'webhooks_create' => 'Crear nuevo Webhook',
|
||||
'webhooks_create' => 'Crear Webhook',
|
||||
'webhooks_none_created' => 'No hay webhooks creados.',
|
||||
'webhooks_edit' => 'Editar Webhook',
|
||||
'webhooks_save' => 'Guardar Webhook',
|
||||
'webhooks_details' => 'Detalles del Webhook',
|
||||
'webhooks_details_desc' => 'Proporcione un nombre y un punto final de POST como destino para enviar los datos del webhook.',
|
||||
'webhooks_details_desc' => 'Proporcione un nombre y un punto final POST como destino para los datos del webhook que se enviarán.',
|
||||
'webhooks_events' => 'Eventos del Webhook',
|
||||
'webhooks_events_desc' => 'Seleccione todos los eventos que deberían activar este webhook.',
|
||||
'webhooks_events_warning' => 'Tenga en cuenta que estos eventos se activarán para todos los eventos seleccionados, incluso si se aplican permisos personalizados. Asegúrese de que el uso de este webhook no exponga contenido confidencial.',
|
||||
'webhooks_events_all' => 'Todos los eventos del sistema',
|
||||
'webhooks_name' => 'Nombre del Webhook',
|
||||
'webhooks_timeout' => 'Tiempo de Espera de Solicitud del Webhook (Segundos)',
|
||||
'webhooks_timeout' => 'Tiempo de Espera de Webhook (Segundos)',
|
||||
'webhooks_endpoint' => 'Punto final del Webhook',
|
||||
'webhooks_active' => 'Webhook Activo',
|
||||
'webhook_events_table_header' => 'Eventos',
|
||||
'webhooks_delete' => 'Eliminar Webhook',
|
||||
'webhooks_delete_warning' => 'Esto eliminará completamente del sistema este webhook con el nombre \':webhookName\'.',
|
||||
'webhooks_delete_confirm' => '¿Está seguro que quiere eliminar este webhook?',
|
||||
'webhooks_delete_warning' => 'Esto eliminará completamente este webhook, con el nombre \':webhookName\', del sistema.',
|
||||
'webhooks_delete_confirm' => '¿Seguro que quieres eliminar este webhook?',
|
||||
'webhooks_format_example' => 'Ejemplo de Formato de Webhook',
|
||||
'webhooks_format_example_desc' => 'Los datos del Webhook, en formato JSON, se envían como una solicitud POST al punto final siguiendo el formato mostrado a continuación. Las propiedades "related_item" y "url" son opcionales y dependerán del tipo de evento activado.',
|
||||
'webhooks_format_example_desc' => 'Los datos del Webhook se envían como una solicitud POST al punto final configurado como JSON siguiendo el formato mostrado a continuación. Las propiedades "related_item" y "url" son opcionales y dependerán del tipo de evento activado.',
|
||||
'webhooks_status' => 'Estado del Webhook',
|
||||
'webhooks_last_called' => 'Última Ejecución:',
|
||||
'webhooks_last_errored' => 'Último error:',
|
||||
|
||||
@@ -28,8 +28,8 @@ return [
|
||||
'create_account' => 'Loo konto',
|
||||
'already_have_account' => 'Kasutajakonto juba olemas?',
|
||||
'dont_have_account' => 'Sul ei ole veel kontot?',
|
||||
'social_login' => 'Sisene läbi sotsiaalmeedia',
|
||||
'social_registration' => 'Registreeru läbi sotsiaalmeedia',
|
||||
'social_login' => 'Social Login',
|
||||
'social_registration' => 'Social Registration',
|
||||
'social_registration_text' => 'Registreeru ja logi sisse välise teenuse kaudu.',
|
||||
|
||||
'register_thanks' => 'Aitäh, et registreerusid!',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Aktiivne',
|
||||
'status_inactive' => 'Mitteaktiivne',
|
||||
'never' => 'Mitte kunagi',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Laienda päisemenüü',
|
||||
|
||||
@@ -23,7 +23,7 @@ return [
|
||||
'meta_updated' => 'Muudetud :timeLength',
|
||||
'meta_updated_name' => 'Muudetud :timeLength kasutaja :user poolt',
|
||||
'meta_owned_name' => 'Kuulub kasutajale :user',
|
||||
'entity_select' => 'Objekti valik',
|
||||
'entity_select' => 'Entity Select',
|
||||
'images' => 'Pildid',
|
||||
'my_recent_drafts' => 'Minu hiljutised mustandid',
|
||||
'my_recently_viewed' => 'Minu viimati vaadatud',
|
||||
|
||||
@@ -7,57 +7,57 @@ return [
|
||||
|
||||
// Pages
|
||||
'page_create' => 'صفحه ایجاد شده',
|
||||
'page_create_notification' => 'صفحه با موفقیت ایجاد شد',
|
||||
'page_create_notification' => 'Page successfully created',
|
||||
'page_update' => 'صفحه بروز شده',
|
||||
'page_update_notification' => 'صفحه با موفقیت به روزرسانی شد',
|
||||
'page_update_notification' => 'Page successfully updated',
|
||||
'page_delete' => 'حذف صفحه',
|
||||
'page_delete_notification' => 'صفحه با موفقیت حذف شد',
|
||||
'page_delete_notification' => 'Page successfully deleted',
|
||||
'page_restore' => 'بازیابی صفحه',
|
||||
'page_restore_notification' => 'صفحه با موفقیت بازیابی شد',
|
||||
'page_restore_notification' => 'Page successfully restored',
|
||||
'page_move' => 'انتقال صفحه',
|
||||
|
||||
// Chapters
|
||||
'chapter_create' => 'ایجاد فصل',
|
||||
'chapter_create_notification' => 'فصل با موفقیت ایجاد شد',
|
||||
'chapter_create_notification' => 'Chapter successfully created',
|
||||
'chapter_update' => 'به روزرسانی فصل',
|
||||
'chapter_update_notification' => 'فصل با موفقیت به روزرسانی شد',
|
||||
'chapter_update_notification' => 'Chapter successfully updated',
|
||||
'chapter_delete' => 'حذف فصل',
|
||||
'chapter_delete_notification' => 'فصل با موفقیت حذف شد',
|
||||
'chapter_delete_notification' => 'Chapter successfully deleted',
|
||||
'chapter_move' => 'انتقال فصل',
|
||||
|
||||
// Books
|
||||
'book_create' => 'ایجاد کتاب',
|
||||
'book_create_notification' => 'کتاب با موفقیت ایجاد شد',
|
||||
'book_create_notification' => 'Book successfully created',
|
||||
'book_update' => 'به روزرسانی کتاب',
|
||||
'book_update_notification' => 'کتاب با موفقیت به روزرسانی شد',
|
||||
'book_update_notification' => 'Book successfully updated',
|
||||
'book_delete' => 'حذف کتاب',
|
||||
'book_delete_notification' => 'کتاب با موفقیت حذف شد',
|
||||
'book_delete_notification' => 'Book successfully deleted',
|
||||
'book_sort' => 'مرتب سازی کتاب',
|
||||
'book_sort_notification' => 'کتاب با موفقیت مرتب سازی شد',
|
||||
'book_sort_notification' => 'Book successfully re-sorted',
|
||||
|
||||
// Bookshelves
|
||||
'bookshelf_create' => 'ایجاد قفسه کتاب',
|
||||
'bookshelf_create_notification' => 'قفسه کتاب با موفقیت ایجاد شد',
|
||||
'bookshelf_create' => 'created bookshelf',
|
||||
'bookshelf_create_notification' => 'Bookshelf successfully created',
|
||||
'bookshelf_update' => 'به روزرسانی قفسه کتاب',
|
||||
'bookshelf_update_notification' => 'قفسه کتاب با موفقیت به روزرسانی شد',
|
||||
'bookshelf_update_notification' => 'Bookshelf successfully updated',
|
||||
'bookshelf_delete' => 'حذف قفسه کتاب',
|
||||
'bookshelf_delete_notification' => 'قفسه کتاب با موفقیت حذف شد',
|
||||
'bookshelf_delete_notification' => 'Bookshelf successfully deleted',
|
||||
|
||||
// Favourites
|
||||
'favourite_add_notification' => '":name" به علاقه مندی های شما اضافه شد',
|
||||
'favourite_remove_notification' => '":name" از علاقه مندی های شما حذف شد',
|
||||
|
||||
// MFA
|
||||
'mfa_setup_method_notification' => 'روش چند فاکتوری با موفقیت پیکربندی شد',
|
||||
'mfa_remove_method_notification' => 'روش چند فاکتوری با موفقیت حذف شد',
|
||||
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
|
||||
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
|
||||
|
||||
// Webhooks
|
||||
'webhook_create' => 'ایجاد وب هوک',
|
||||
'webhook_create_notification' => 'وب هوک با موفقیت ایجاد شد',
|
||||
'webhook_update' => 'به روزرسانی وب هوک',
|
||||
'webhook_update_notification' => 'وب هوک با موفقیت بروزرسانی شد',
|
||||
'webhook_delete' => 'حذف وب هوک',
|
||||
'webhook_delete_notification' => 'وب هوک با موفقیت حذف شد',
|
||||
'webhook_create' => 'created webhook',
|
||||
'webhook_create_notification' => 'Webhook successfully created',
|
||||
'webhook_update' => 'updated webhook',
|
||||
'webhook_update_notification' => 'Webhook successfully updated',
|
||||
'webhook_delete' => 'deleted webhook',
|
||||
'webhook_delete_notification' => 'Webhook successfully deleted',
|
||||
|
||||
// Other
|
||||
'commented_on' => 'ثبت دیدگاه',
|
||||
|
||||
@@ -21,7 +21,7 @@ return [
|
||||
'email' => 'پست الکترونیک',
|
||||
'password' => 'کلمه عبور',
|
||||
'password_confirm' => 'تایید کلمه عبور',
|
||||
'password_hint' => 'باید بیش از 8 کاراکتر باشد',
|
||||
'password_hint' => 'Must be at least 8 characters',
|
||||
'forgot_password' => 'کلمه عبور خود را فراموش کرده اید؟',
|
||||
'remember_me' => 'مرا به خاطر بسپار',
|
||||
'ldap_email_hint' => 'لطفا برای استفاده از این حساب کاربری پست الکترونیک وارد نمایید.',
|
||||
@@ -54,7 +54,7 @@ return [
|
||||
'email_confirm_text' => 'لطفا با کلیک بر روی دکمه زیر پست الکترونیک خود را تایید نمایید:',
|
||||
'email_confirm_action' => 'تایید پست الکترونیک',
|
||||
'email_confirm_send_error' => 'تایید پست الکترونیک الزامی می باشد، اما سیستم قادر به ارسال پیام نمی باشد.',
|
||||
'email_confirm_success' => 'ایمیل شما تایید شد! اکنون باید بتوانید با استفاده از این آدرس ایمیل وارد شوید.',
|
||||
'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.',
|
||||
'email_confirm_resent' => 'پیام تایید پست الکترونیک مجدد ارسال گردید، لطفا صندوق ورودی خود را بررسی نمایید.',
|
||||
|
||||
'email_not_confirmed' => 'پست الکترونیک تایید نشده است',
|
||||
@@ -71,7 +71,7 @@ return [
|
||||
'user_invite_page_welcome' => 'به :appName خوش آمدید!',
|
||||
'user_invite_page_text' => 'برای نهایی کردن حساب کاربری خود در :appName و دسترسی به آن، می بایست یک کلمه عبور تنظیم نمایید.',
|
||||
'user_invite_page_confirm_button' => 'تایید کلمه عبور',
|
||||
'user_invite_success_login' => 'رمز عبور تنظیم شده است، اکنون باید بتوانید با استفاده از رمز عبور تعیین شده خود وارد شوید تا به :appName دسترسی پیدا کنید!',
|
||||
'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!',
|
||||
|
||||
// Multi-factor Authentication
|
||||
'mfa_setup' => 'تنظیم احراز هویت چند مرحلهای',
|
||||
@@ -80,31 +80,31 @@ return [
|
||||
'mfa_setup_reconfigure' => 'تنظیم مجدد',
|
||||
'mfa_setup_remove_confirmation' => 'از حذف احراز هویت چند مرحله ای اطمینان دارید؟',
|
||||
'mfa_setup_action' => 'تنظیم',
|
||||
'mfa_backup_codes_usage_limit_warning' => 'کمتر از 5 کد پشتیبان باقی مانده است، لطفاً قبل از تمام شدن کدها یک مجموعه جدید ایجاد و ذخیره کنید تا از قفل شدن حساب خود جلوگیری کنید.',
|
||||
'mfa_option_totp_title' => 'برنامه ی موبایل',
|
||||
'mfa_option_totp_desc' => 'برای استفاده از احراز هویت چند عاملی به یک برنامه موبایلی نیاز دارید که از TOTP پشتیبانی کند، مانند Google Authenticator، Authy یا Microsoft Authenticator.',
|
||||
'mfa_option_backup_codes_title' => 'کدهای پشتیبان',
|
||||
'mfa_option_backup_codes_desc' => 'مجموعه ای از کدهای پشتیبان یکبار مصرف را ایمن ذخیره کنید که می توانید برای تأیید هویت خود وارد کنید.',
|
||||
'mfa_gen_confirm_and_enable' => 'تایید و فعال کنید',
|
||||
'mfa_gen_backup_codes_title' => 'راه اندازی کدهای پشتیبان',
|
||||
'mfa_gen_backup_codes_desc' => 'لیست کدهای زیر را در مکانی امن ذخیره کنید. هنگام دسترسی به سیستم، می توانید از یکی از کدها به عنوان مکانیزم احراز هویت دوم استفاده کنید.',
|
||||
'mfa_gen_backup_codes_download' => 'دانلود کدها',
|
||||
'mfa_gen_backup_codes_usage_warning' => 'هر کد فقط یک بار قابل استفاده است',
|
||||
'mfa_gen_totp_title' => 'راه اندازی اپلیکیشن موبایل',
|
||||
'mfa_gen_totp_desc' => 'برای استفاده از احراز هویت چند عاملی به یک برنامه موبایلی نیاز دارید که از TOTP پشتیبانی کند، مانند Google Authenticator، Authy یا Microsoft Authenticator.',
|
||||
'mfa_gen_totp_scan' => 'برای شروع، کد QR زیر را با استفاده از برنامه احراز هویت ترجیحی خود اسکن کنید.',
|
||||
'mfa_gen_totp_verify_setup' => 'تأیید تنظیمات',
|
||||
'mfa_gen_totp_verify_setup_desc' => 'با وارد کردن کدی که در برنامه احراز هویت شما ایجاد شده است، در کادر ورودی زیر، مطمئن شوید که همه کار می کنند:',
|
||||
'mfa_gen_totp_provide_code_here' => 'کد تولید شده برنامه خود را در اینجا ارائه دهید',
|
||||
'mfa_verify_access' => 'تأیید دسترسی',
|
||||
'mfa_verify_access_desc' => 'قبل از اینکه به شما اجازه دسترسی داده شود، حساب کاربری شما از شما می خواهد که هویت خود را از طریق یک سطح تأیید اضافی تأیید کنید. برای ادامه، با استفاده از یکی از روش های پیکربندی شده خود، تأیید کنید.',
|
||||
'mfa_verify_no_methods' => 'هیچ روشی پیکربندی نشده است',
|
||||
'mfa_verify_no_methods_desc' => 'هیچ روش احراز هویت چند عاملی برای حساب شما یافت نشد. قبل از دسترسی، باید حداقل یک روش را تنظیم کنید.',
|
||||
'mfa_verify_use_totp' => 'با استفاده از یک برنامه تلفن همراه تأیید کنید',
|
||||
'mfa_verify_use_backup_codes' => 'با استفاده از یک کد پشتیبان تأیید کنید',
|
||||
'mfa_verify_backup_code' => 'کد پشتیبان',
|
||||
'mfa_verify_backup_code_desc' => 'یکی از کدهای پشتیبان باقی مانده خود را در زیر وارد کنید:',
|
||||
'mfa_verify_backup_code_enter_here' => 'کد پشتیبان را در اینجا وارد کنید',
|
||||
'mfa_verify_totp_desc' => 'کد ایجاد شده با استفاده از برنامه تلفن همراه خود را در زیر وارد کنید:',
|
||||
'mfa_setup_login_notification' => 'روش چند عاملی پیکربندی شد، لطفاً اکنون دوباره با استفاده از روش پیکربندی شده وارد شوید.',
|
||||
'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
|
||||
'mfa_option_totp_title' => 'Mobile App',
|
||||
'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
|
||||
'mfa_option_backup_codes_title' => 'Backup Codes',
|
||||
'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
|
||||
'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
|
||||
'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
|
||||
'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
|
||||
'mfa_gen_backup_codes_download' => 'Download Codes',
|
||||
'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
|
||||
'mfa_gen_totp_title' => 'Mobile App Setup',
|
||||
'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
|
||||
'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
|
||||
'mfa_gen_totp_verify_setup' => 'Verify Setup',
|
||||
'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
|
||||
'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
|
||||
'mfa_verify_access' => 'Verify Access',
|
||||
'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
|
||||
'mfa_verify_no_methods' => 'No Methods Configured',
|
||||
'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
|
||||
'mfa_verify_use_totp' => 'Verify using a mobile app',
|
||||
'mfa_verify_use_backup_codes' => 'Verify using a backup code',
|
||||
'mfa_verify_backup_code' => 'Backup Code',
|
||||
'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
|
||||
'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
|
||||
'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
|
||||
'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
|
||||
];
|
||||
|
||||
@@ -39,14 +39,14 @@ return [
|
||||
'reset' => 'بازنشانی',
|
||||
'remove' => 'حذف',
|
||||
'add' => 'ﺍﻓﺰﻭﺩﻥ',
|
||||
'configure' => 'پیکربندی کنید',
|
||||
'configure' => 'Configure',
|
||||
'fullscreen' => 'تمام صفحه',
|
||||
'favourite' => 'علاقهمندی',
|
||||
'unfavourite' => 'Unfavourite',
|
||||
'next' => 'بعدی',
|
||||
'previous' => 'قبلى',
|
||||
'filter_active' => 'فیلتر فعال:',
|
||||
'filter_clear' => 'پاک کردن فیلتر',
|
||||
'filter_active' => 'Active Filter:',
|
||||
'filter_clear' => 'Clear Filter',
|
||||
|
||||
// Sort Options
|
||||
'sort_options' => 'گزینههای مرتب سازی',
|
||||
@@ -71,11 +71,10 @@ return [
|
||||
'list_view' => 'نمای لیست',
|
||||
'default' => 'پیشفرض',
|
||||
'breadcrumb' => 'مسیر جاری',
|
||||
'status' => 'وضعیت',
|
||||
'status_active' => 'فعال',
|
||||
'status_inactive' => 'غیر فعال',
|
||||
'never' => 'هرگز',
|
||||
'none' => 'None',
|
||||
'status' => 'Status',
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'گسترش منو',
|
||||
|
||||
@@ -60,288 +60,288 @@ return [
|
||||
'search_options' => 'گزینه ها',
|
||||
'search_viewed_by_me' => 'بازدید شده به وسیله من',
|
||||
'search_not_viewed_by_me' => 'توسط من مشاهده نشده است',
|
||||
'search_permissions_set' => 'مجوزها تنظیم شده است',
|
||||
'search_created_by_me' => 'ایجاد شده توسط من',
|
||||
'search_updated_by_me' => 'به روز شده توسط من',
|
||||
'search_owned_by_me' => 'متعلق به من است',
|
||||
'search_date_options' => 'گزینه های تاریخ',
|
||||
'search_updated_before' => 'قبلا به روز شده',
|
||||
'search_updated_after' => 'پس از به روز رسانی',
|
||||
'search_created_before' => 'قبلا ایجاد شده است',
|
||||
'search_created_after' => 'ایجاد شده پس از',
|
||||
'search_set_date' => 'تنظیم تاریخ',
|
||||
'search_update' => 'جستجو را به روز کنید',
|
||||
'search_permissions_set' => 'Permissions set',
|
||||
'search_created_by_me' => 'Created by me',
|
||||
'search_updated_by_me' => 'Updated by me',
|
||||
'search_owned_by_me' => 'Owned by me',
|
||||
'search_date_options' => 'Date Options',
|
||||
'search_updated_before' => 'Updated before',
|
||||
'search_updated_after' => 'Updated after',
|
||||
'search_created_before' => 'Created before',
|
||||
'search_created_after' => 'Created after',
|
||||
'search_set_date' => 'Set Date',
|
||||
'search_update' => 'Update Search',
|
||||
|
||||
// Shelves
|
||||
'shelf' => 'تاقچه',
|
||||
'shelves' => 'قفسه ها',
|
||||
'x_shelves' => ':count تاقچه|:count تاقچه',
|
||||
'shelves_long' => 'قفسه کتاب',
|
||||
'shelves_empty' => 'هیچ قفسه ای ایجاد نشده است',
|
||||
'shelf' => 'Shelf',
|
||||
'shelves' => 'Shelves',
|
||||
'x_shelves' => ':count Shelf|:count Shelves',
|
||||
'shelves_long' => 'Bookshelves',
|
||||
'shelves_empty' => 'No shelves have been created',
|
||||
'shelves_create' => 'Create New Shelf',
|
||||
'shelves_popular' => 'قفسه های محبوب',
|
||||
'shelves_new' => 'قفسه های جدید',
|
||||
'shelves_new_action' => 'قفسه جدید',
|
||||
'shelves_popular_empty' => 'محبوب ترین قفسه ها در اینجا ظاهر می شوند.',
|
||||
'shelves_new_empty' => 'جدیدترین قفسه های ایجاد شده در اینجا ظاهر می شوند.',
|
||||
'shelves_save' => 'ذخیره قفسه',
|
||||
'shelves_books' => 'کتاب های موجود در این قفسه',
|
||||
'shelves_add_books' => 'کتاب ها را به این قفسه اضافه کنید',
|
||||
'shelves_drag_books' => 'کتابها را به اینجا بکشید تا به این قفسه اضافه شوند',
|
||||
'shelves_empty_contents' => 'این قفسه هیچ کتابی به آن اختصاص داده نشده است',
|
||||
'shelves_edit_and_assign' => 'برای اختصاص کتابها، قفسه را ویرایش کنید',
|
||||
'shelves_edit_named' => 'ویرایش قفسه کتاب :name',
|
||||
'shelves_edit' => 'ویرایش قفسه کتاب',
|
||||
'shelves_delete' => 'حذف قفسه کتاب',
|
||||
'shelves_delete_named' => 'حذف قفسه کتاب :name',
|
||||
'shelves_delete_explain' => "با این کار قفسه کتاب با نام ':name' حذف می شود. کتاب های موجود حذف نمی شوند.",
|
||||
'shelves_delete_confirmation' => 'آیا مطمئنید که می خواهید این قفسه کتاب را حذف کنید؟',
|
||||
'shelves_permissions' => 'مجوزهای قفسه کتاب',
|
||||
'shelves_permissions_updated' => 'مجوزهای قفسه کتاب به روز شد',
|
||||
'shelves_permissions_active' => 'مجوزهای قفسه کتاب فعال است',
|
||||
'shelves_permissions_cascade_warning' => 'مجوزهای موجود در قفسههای کتاب به طور خودکار به کتابهای حاوی آبشار نمیشوند. این به این دلیل است که یک کتاب می تواند در چندین قفسه وجود داشته باشد. با این حال، مجوزها را میتوان با استفاده از گزینه زیر در کتابهای کودک کپی کرد.',
|
||||
'shelves_copy_permissions_to_books' => 'کپی مجوزها در کتابها',
|
||||
'shelves_copy_permissions' => 'مجوزهای کپی',
|
||||
'shelves_copy_permissions_explain' => 'با این کار تنظیمات مجوز فعلی این قفسه کتاب برای همه کتابهای موجود در آن اعمال میشود. قبل از فعال کردن، مطمئن شوید که هر گونه تغییر در مجوزهای این قفسه کتاب ذخیره شده است.',
|
||||
'shelves_copy_permission_success' => 'مجوزهای قفسه کتاب در :count books کپی شد',
|
||||
'shelves_popular' => 'Popular Shelves',
|
||||
'shelves_new' => 'New Shelves',
|
||||
'shelves_new_action' => 'New Shelf',
|
||||
'shelves_popular_empty' => 'The most popular shelves will appear here.',
|
||||
'shelves_new_empty' => 'The most recently created shelves will appear here.',
|
||||
'shelves_save' => 'Save Shelf',
|
||||
'shelves_books' => 'Books on this shelf',
|
||||
'shelves_add_books' => 'Add books to this shelf',
|
||||
'shelves_drag_books' => 'Drag books here to add them to this shelf',
|
||||
'shelves_empty_contents' => 'This shelf has no books assigned to it',
|
||||
'shelves_edit_and_assign' => 'Edit shelf to assign books',
|
||||
'shelves_edit_named' => 'Edit Bookshelf :name',
|
||||
'shelves_edit' => 'Edit Bookshelf',
|
||||
'shelves_delete' => 'Delete Bookshelf',
|
||||
'shelves_delete_named' => 'Delete Bookshelf :name',
|
||||
'shelves_delete_explain' => "This will delete the bookshelf with the name ':name'. Contained books will not be deleted.",
|
||||
'shelves_delete_confirmation' => 'Are you sure you want to delete this bookshelf?',
|
||||
'shelves_permissions' => 'Bookshelf Permissions',
|
||||
'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
|
||||
'shelves_permissions_active' => 'Bookshelf Permissions Active',
|
||||
'shelves_permissions_cascade_warning' => 'Permissions on bookshelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.',
|
||||
'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
|
||||
'shelves_copy_permissions' => 'Copy Permissions',
|
||||
'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
|
||||
'shelves_copy_permission_success' => 'Bookshelf permissions copied to :count books',
|
||||
|
||||
// Books
|
||||
'book' => 'کتاب',
|
||||
'books' => 'کتابها',
|
||||
'x_books' => ':count کتاب|:count کتاب',
|
||||
'books_empty' => 'هیچ کتابی ایجاد نشده است',
|
||||
'books_popular' => 'کتاب های محبوب',
|
||||
'books_recent' => 'کتاب های اخیر',
|
||||
'books_new' => 'کتاب های جدید',
|
||||
'books_new_action' => 'کتاب جدید',
|
||||
'books_popular_empty' => 'محبوب ترین کتاب ها در اینجا ظاهر می شوند.',
|
||||
'books_new_empty' => 'جدیدترین کتابهای ایجاد شده در اینجا ظاهر میشوند.',
|
||||
'books_create' => 'ایجاد کتاب جدید',
|
||||
'books_delete' => 'حذف کتاب',
|
||||
'books_delete_named' => 'حذف کتاب:bookName',
|
||||
'books_delete_explain' => 'با این کار کتابی با نام \':bookName\' حذف می شود. تمام صفحات و فصل ها حذف خواهند شد.',
|
||||
'books_delete_confirmation' => 'آیا مطمئن هستید که می خواهید این کتاب را حذف کنید؟',
|
||||
'books_edit' => 'ویرایش کتاب',
|
||||
'books_edit_named' => 'ویرایش کتاب:bookName',
|
||||
'books_form_book_name' => 'نام کتاب',
|
||||
'books_save' => 'ذخیره کتاب',
|
||||
'books_permissions' => 'مجوزهای کتاب',
|
||||
'books_permissions_updated' => 'مجوزهای کتاب به روز شد',
|
||||
'books_empty_contents' => 'هیچ صفحه یا فصلی برای این کتاب ایجاد نشده است.',
|
||||
'books_empty_create_page' => 'یک صفحه جدید ایجاد کنید',
|
||||
'books_empty_sort_current_book' => 'کتاب فعلی را مرتب کنید',
|
||||
'books_empty_add_chapter' => 'یک فصل اضافه کنید',
|
||||
'books_permissions_active' => 'مجوزهای کتاب فعال است',
|
||||
'books_search_this' => 'این کتاب را جستجو کنید',
|
||||
'books_navigation' => 'ناوبری کتاب',
|
||||
'books_sort' => 'مرتب سازی مطالب کتاب',
|
||||
'books_sort_named' => 'مرتب سازی کتاب:bookName',
|
||||
'books_sort_name' => 'مرتب سازی بر اساس نام',
|
||||
'books_sort_created' => 'مرتب سازی بر اساس تاریخ ایجاد',
|
||||
'books_sort_updated' => 'مرتب سازی بر اساس تاریخ به روز رسانی',
|
||||
'books_sort_chapters_first' => 'فصل اول',
|
||||
'books_sort_chapters_last' => 'فصل آخر',
|
||||
'books_sort_show_other' => 'نمایش کتاب های دیگر',
|
||||
'books_sort_save' => 'ذخیره سفارش جدید',
|
||||
'books_copy' => 'کپی کتاب',
|
||||
'books_copy_success' => 'کتاب با موفقیت کپی شد',
|
||||
'book' => 'Book',
|
||||
'books' => 'Books',
|
||||
'x_books' => ':count Book|:count Books',
|
||||
'books_empty' => 'No books have been created',
|
||||
'books_popular' => 'Popular Books',
|
||||
'books_recent' => 'Recent Books',
|
||||
'books_new' => 'New Books',
|
||||
'books_new_action' => 'New Book',
|
||||
'books_popular_empty' => 'The most popular books will appear here.',
|
||||
'books_new_empty' => 'The most recently created books will appear here.',
|
||||
'books_create' => 'Create New Book',
|
||||
'books_delete' => 'Delete Book',
|
||||
'books_delete_named' => 'Delete Book :bookName',
|
||||
'books_delete_explain' => 'This will delete the book with the name \':bookName\'. All pages and chapters will be removed.',
|
||||
'books_delete_confirmation' => 'Are you sure you want to delete this book?',
|
||||
'books_edit' => 'Edit Book',
|
||||
'books_edit_named' => 'Edit Book :bookName',
|
||||
'books_form_book_name' => 'Book Name',
|
||||
'books_save' => 'Save Book',
|
||||
'books_permissions' => 'Book Permissions',
|
||||
'books_permissions_updated' => 'Book Permissions Updated',
|
||||
'books_empty_contents' => 'No pages or chapters have been created for this book.',
|
||||
'books_empty_create_page' => 'Create a new page',
|
||||
'books_empty_sort_current_book' => 'Sort the current book',
|
||||
'books_empty_add_chapter' => 'Add a chapter',
|
||||
'books_permissions_active' => 'Book Permissions Active',
|
||||
'books_search_this' => 'Search this book',
|
||||
'books_navigation' => 'Book Navigation',
|
||||
'books_sort' => 'Sort Book Contents',
|
||||
'books_sort_named' => 'Sort Book :bookName',
|
||||
'books_sort_name' => 'Sort by Name',
|
||||
'books_sort_created' => 'Sort by Created Date',
|
||||
'books_sort_updated' => 'Sort by Updated Date',
|
||||
'books_sort_chapters_first' => 'Chapters First',
|
||||
'books_sort_chapters_last' => 'Chapters Last',
|
||||
'books_sort_show_other' => 'Show Other Books',
|
||||
'books_sort_save' => 'Save New Order',
|
||||
'books_copy' => 'Copy Book',
|
||||
'books_copy_success' => 'Book successfully copied',
|
||||
|
||||
// Chapters
|
||||
'chapter' => 'فصل',
|
||||
'chapters' => 'فصل',
|
||||
'x_chapters' => ':count فصل|:count فصل',
|
||||
'chapters_popular' => 'فصل های محبوب',
|
||||
'chapters_new' => 'فصل جدید',
|
||||
'chapters_create' => 'ایجاد فصل جدید',
|
||||
'chapters_delete' => 'حذف فصل',
|
||||
'chapters_delete_named' => 'حذف فصل :chapterName',
|
||||
'chapters_delete_explain' => 'با این کار فصلی با نام \':chapterName\' حذف می شود. تمامی صفحاتی که در این فصل وجود دارند نیز حذف خواهند شد.',
|
||||
'chapters_delete_confirm' => 'آیا مطمئن هستید که می خواهید این فصل را حذف کنید؟',
|
||||
'chapters_edit' => 'ویرایش فصل',
|
||||
'chapters_edit_named' => 'ویرایش فصل :chapterName',
|
||||
'chapters_save' => 'ذخیره فصل',
|
||||
'chapters_move' => 'انتقال فصل',
|
||||
'chapters_move_named' => 'انتقال فصل :chapterName',
|
||||
'chapter_move_success' => 'فصل به :bookName منتقل شد',
|
||||
'chapters_copy' => 'کپی کردن فصل',
|
||||
'chapters_copy_success' => 'فصل با موفقیت کپی شد',
|
||||
'chapters_permissions' => 'مجوزهای فصل',
|
||||
'chapters_empty' => 'در حال حاضر هیچ صفحه ای در این فصل وجود ندارد.',
|
||||
'chapters_permissions_active' => 'مجوزهای فصل فعال است',
|
||||
'chapters_permissions_success' => 'مجوزهای فصل به روز شد',
|
||||
'chapters_search_this' => 'این فصل را جستجو کنید',
|
||||
'chapter' => 'Chapter',
|
||||
'chapters' => 'Chapters',
|
||||
'x_chapters' => ':count Chapter|:count Chapters',
|
||||
'chapters_popular' => 'Popular Chapters',
|
||||
'chapters_new' => 'New Chapter',
|
||||
'chapters_create' => 'Create New Chapter',
|
||||
'chapters_delete' => 'Delete Chapter',
|
||||
'chapters_delete_named' => 'Delete Chapter :chapterName',
|
||||
'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
|
||||
'chapters_delete_confirm' => 'Are you sure you want to delete this chapter?',
|
||||
'chapters_edit' => 'Edit Chapter',
|
||||
'chapters_edit_named' => 'Edit Chapter :chapterName',
|
||||
'chapters_save' => 'Save Chapter',
|
||||
'chapters_move' => 'Move Chapter',
|
||||
'chapters_move_named' => 'Move Chapter :chapterName',
|
||||
'chapter_move_success' => 'Chapter moved to :bookName',
|
||||
'chapters_copy' => 'Copy Chapter',
|
||||
'chapters_copy_success' => 'Chapter successfully copied',
|
||||
'chapters_permissions' => 'Chapter Permissions',
|
||||
'chapters_empty' => 'No pages are currently in this chapter.',
|
||||
'chapters_permissions_active' => 'Chapter Permissions Active',
|
||||
'chapters_permissions_success' => 'Chapter Permissions Updated',
|
||||
'chapters_search_this' => 'Search this chapter',
|
||||
|
||||
// Pages
|
||||
'page' => 'صفحه',
|
||||
'pages' => 'صفحات',
|
||||
'x_pages' => ':count صفحه|:count صفحه',
|
||||
'pages_popular' => 'صفحات محبوب',
|
||||
'pages_new' => 'صفحه جدید',
|
||||
'pages_attachments' => 'پیوستها',
|
||||
'pages_navigation' => 'پیمایش صفحه',
|
||||
'pages_delete' => 'حذف صفحه',
|
||||
'pages_delete_named' => 'حذف صفحه:pageName',
|
||||
'pages_delete_draft_named' => 'حذف پیش نویس صفحه:pageName',
|
||||
'pages_delete_draft' => 'حذف صفحه پیش نویس',
|
||||
'pages_delete_success' => 'صفحه حذف شد',
|
||||
'pages_delete_draft_success' => 'صفحه پیش نویس حذف شد',
|
||||
'pages_delete_confirm' => 'آیا مطمئن هستید که می خواهید این صفحه را حذف کنید؟',
|
||||
'pages_delete_draft_confirm' => 'آیا مطمئن هستید که می خواهید این صفحه پیش نویس را حذف کنید؟',
|
||||
'pages_editing_named' => 'ویرایش صفحه :pageName',
|
||||
'pages_edit_draft_options' => 'گزینه های پیش نویس',
|
||||
'pages_edit_save_draft' => 'ذخیره پیش نویس',
|
||||
'pages_edit_draft' => 'ویرایش پیش نویس صفحه',
|
||||
'pages_editing_draft' => 'در حال ویرایش پیش نویس',
|
||||
'pages_editing_page' => 'در حال ویرایش صفحه',
|
||||
'pages_edit_draft_save_at' => 'پیش نویس ذخیره شده در',
|
||||
'pages_edit_delete_draft' => 'حذف پیش نویس',
|
||||
'pages_edit_discard_draft' => 'دور انداختن پیش نویس',
|
||||
'pages_edit_set_changelog' => 'تنظیم تغییرات',
|
||||
'pages_edit_enter_changelog_desc' => 'توضیح مختصری از تغییراتی که ایجاد کرده اید وارد کنید',
|
||||
'pages_edit_enter_changelog' => 'وارد کردن تغییرات',
|
||||
'pages_save' => 'ذخیره صفحه',
|
||||
'pages_title' => 'عنوان صفحه',
|
||||
'pages_name' => 'نام صفحه',
|
||||
'pages_md_editor' => 'ویرایشگر',
|
||||
'pages_md_preview' => 'پيش نمايش',
|
||||
'pages_md_insert_image' => 'درج تصویر',
|
||||
'pages_md_insert_link' => 'پیوند نهاد را درج کنید',
|
||||
'pages_md_insert_drawing' => 'درج طرح',
|
||||
'pages_not_in_chapter' => 'صفحه در یک فصل نیست',
|
||||
'pages_move' => 'انتقال صفحه',
|
||||
'pages_move_success' => 'صفحه به ":parentName" منتقل شد',
|
||||
'pages_copy' => 'صفحه را کپی کنید',
|
||||
'pages_copy_desination' => 'مقصد را کپی کنید',
|
||||
'pages_copy_success' => 'صفحه با موفقیت کپی شد',
|
||||
'pages_permissions' => 'مجوزهای صفحه',
|
||||
'pages_permissions_success' => 'مجوزهای صفحه به روز شد',
|
||||
'pages_revision' => 'تجدید نظر',
|
||||
'pages_revisions' => 'ویرایش های صفحه',
|
||||
'pages_revisions_named' => 'بازبینی صفحه برای :pageName',
|
||||
'pages_revision_named' => 'ویرایش صفحه برای :pageName',
|
||||
'pages_revision_restored_from' => 'بازیابی شده از #:id; :summary',
|
||||
'pages_revisions_created_by' => 'ایجاد شده توسط',
|
||||
'pages_revisions_date' => 'تاریخ تجدید نظر',
|
||||
'page' => 'Page',
|
||||
'pages' => 'Pages',
|
||||
'x_pages' => ':count Page|:count Pages',
|
||||
'pages_popular' => 'Popular Pages',
|
||||
'pages_new' => 'New Page',
|
||||
'pages_attachments' => 'Attachments',
|
||||
'pages_navigation' => 'Page Navigation',
|
||||
'pages_delete' => 'Delete Page',
|
||||
'pages_delete_named' => 'Delete Page :pageName',
|
||||
'pages_delete_draft_named' => 'Delete Draft Page :pageName',
|
||||
'pages_delete_draft' => 'Delete Draft Page',
|
||||
'pages_delete_success' => 'Page deleted',
|
||||
'pages_delete_draft_success' => 'Draft page deleted',
|
||||
'pages_delete_confirm' => 'Are you sure you want to delete this page?',
|
||||
'pages_delete_draft_confirm' => 'Are you sure you want to delete this draft page?',
|
||||
'pages_editing_named' => 'Editing Page :pageName',
|
||||
'pages_edit_draft_options' => 'Draft Options',
|
||||
'pages_edit_save_draft' => 'Save Draft',
|
||||
'pages_edit_draft' => 'Edit Page Draft',
|
||||
'pages_editing_draft' => 'Editing Draft',
|
||||
'pages_editing_page' => 'Editing Page',
|
||||
'pages_edit_draft_save_at' => 'Draft saved at ',
|
||||
'pages_edit_delete_draft' => 'Delete Draft',
|
||||
'pages_edit_discard_draft' => 'Discard Draft',
|
||||
'pages_edit_set_changelog' => 'Set Changelog',
|
||||
'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
|
||||
'pages_edit_enter_changelog' => 'Enter Changelog',
|
||||
'pages_save' => 'Save Page',
|
||||
'pages_title' => 'Page Title',
|
||||
'pages_name' => 'Page Name',
|
||||
'pages_md_editor' => 'Editor',
|
||||
'pages_md_preview' => 'Preview',
|
||||
'pages_md_insert_image' => 'Insert Image',
|
||||
'pages_md_insert_link' => 'Insert Entity Link',
|
||||
'pages_md_insert_drawing' => 'Insert Drawing',
|
||||
'pages_not_in_chapter' => 'Page is not in a chapter',
|
||||
'pages_move' => 'Move Page',
|
||||
'pages_move_success' => 'Page moved to ":parentName"',
|
||||
'pages_copy' => 'Copy Page',
|
||||
'pages_copy_desination' => 'Copy Destination',
|
||||
'pages_copy_success' => 'Page successfully copied',
|
||||
'pages_permissions' => 'Page Permissions',
|
||||
'pages_permissions_success' => 'Page permissions updated',
|
||||
'pages_revision' => 'Revision',
|
||||
'pages_revisions' => 'Page Revisions',
|
||||
'pages_revisions_named' => 'Page Revisions for :pageName',
|
||||
'pages_revision_named' => 'Page Revision for :pageName',
|
||||
'pages_revision_restored_from' => 'Restored from #:id; :summary',
|
||||
'pages_revisions_created_by' => 'Created By',
|
||||
'pages_revisions_date' => 'Revision Date',
|
||||
'pages_revisions_number' => '#',
|
||||
'pages_revisions_numbered' => 'تجدید نظر #:id',
|
||||
'pages_revisions_numbered_changes' => 'بازبینی #:id تغییرات',
|
||||
'pages_revisions_changelog' => 'لیست تغییرات',
|
||||
'pages_revisions_changes' => 'تغییرات',
|
||||
'pages_revisions_current' => 'نسخهی جاری',
|
||||
'pages_revisions_preview' => 'پيش نمايش',
|
||||
'pages_revisions_restore' => 'بازگرداندن',
|
||||
'pages_revisions_none' => 'این صفحه هیچ ویرایشی ندارد',
|
||||
'pages_copy_link' => 'کپی لینک',
|
||||
'pages_edit_content_link' => 'ویرایش محتوا',
|
||||
'pages_permissions_active' => 'مجوزهای صفحه فعال است',
|
||||
'pages_initial_revision' => 'انتشار اولیه',
|
||||
'pages_initial_name' => 'برگهٔ تازه',
|
||||
'pages_editing_draft_notification' => 'شما در حال ویرایش پیش نویسی هستید که آخرین بار در :timeDiff ذخیره شده است.',
|
||||
'pages_draft_edited_notification' => 'این صفحه از همان زمان به روز شده است. توصیه می شود از این پیش نویس صرف نظر کنید.',
|
||||
'pages_draft_page_changed_since_creation' => 'این صفحه از زمان ایجاد این پیش نویس به روز شده است. توصیه میشود که این پیشنویس را کنار بگذارید یا مراقب باشید که تغییرات صفحه را بازنویسی نکنید.',
|
||||
'pages_revisions_numbered' => 'Revision #:id',
|
||||
'pages_revisions_numbered_changes' => 'Revision #:id Changes',
|
||||
'pages_revisions_changelog' => 'Changelog',
|
||||
'pages_revisions_changes' => 'Changes',
|
||||
'pages_revisions_current' => 'Current Version',
|
||||
'pages_revisions_preview' => 'Preview',
|
||||
'pages_revisions_restore' => 'Restore',
|
||||
'pages_revisions_none' => 'This page has no revisions',
|
||||
'pages_copy_link' => 'Copy Link',
|
||||
'pages_edit_content_link' => 'Edit Content',
|
||||
'pages_permissions_active' => 'Page Permissions Active',
|
||||
'pages_initial_revision' => 'Initial publish',
|
||||
'pages_initial_name' => 'New Page',
|
||||
'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.',
|
||||
'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.',
|
||||
'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
|
||||
'pages_draft_edit_active' => [
|
||||
'start_a' => ':count کاربران شروع به ویرایش این صفحه کرده اند',
|
||||
'start_b' => ':userName ویرایش این صفحه را شروع کرده است',
|
||||
'time_a' => 'از آخرین به روز رسانی صفحه',
|
||||
'time_b' => 'در آخرین دقیقه :minCount',
|
||||
'message' => ':start :time. مراقب باشید به روز رسانی های یکدیگر را بازنویسی نکنید!',
|
||||
'start_a' => ':count users have started editing this page',
|
||||
'start_b' => ':userName has started editing this page',
|
||||
'time_a' => 'since the page was last updated',
|
||||
'time_b' => 'in the last :minCount minutes',
|
||||
'message' => ':start :time. Take care not to overwrite each other\'s updates!',
|
||||
],
|
||||
'pages_draft_discarded' => 'پیش نویس حذف شد، ویرایشگر با محتوای صفحه فعلی به روز شده است',
|
||||
'pages_specific' => 'صفحه خاص',
|
||||
'pages_is_template' => 'الگوی صفحه',
|
||||
'pages_draft_discarded' => 'Draft discarded, The editor has been updated with the current page content',
|
||||
'pages_specific' => 'Specific Page',
|
||||
'pages_is_template' => 'Page Template',
|
||||
|
||||
// Editor Sidebar
|
||||
'page_tags' => 'تگ های صفحه',
|
||||
'chapter_tags' => 'برچسب های فصل',
|
||||
'book_tags' => 'برچسب های کتاب',
|
||||
'shelf_tags' => 'برچسب های قفسه',
|
||||
'tag' => 'برچسب',
|
||||
'tags' => 'برچسب ها',
|
||||
'tag_name' => 'نام برچسب',
|
||||
'tag_value' => 'مقدار برچسب (اختیاری)',
|
||||
'tags_explain' => "برای دسته بندی بهتر مطالب خود چند تگ اضافه کنید.\n می توانید برای سازماندهی عمیق تر، یک مقدار به یک برچسب اختصاص دهید.",
|
||||
'tags_add' => 'یک برچسب دیگر اضافه کنید',
|
||||
'tags_remove' => 'این تگ را حذف کنید',
|
||||
'tags_usages' => 'مجموع استفاده از برچسب',
|
||||
'tags_assigned_pages' => 'به صفحات اختصاص داده شده است',
|
||||
'tags_assigned_chapters' => 'اختصاص به فصل',
|
||||
'tags_assigned_books' => 'به کتاب ها اختصاص داده شده است',
|
||||
'tags_assigned_shelves' => 'به قفسه ها اختصاص داده شده است',
|
||||
'tags_x_unique_values' => ':count مقادیر منحصر به فرد',
|
||||
'tags_all_values' => 'همه ارزش ها',
|
||||
'tags_view_tags' => 'مشاهده برچسب ها',
|
||||
'tags_view_existing_tags' => 'مشاهده تگ های موجود',
|
||||
'tags_list_empty_hint' => 'برچسب ها را می توان از طریق نوار کناری ویرایشگر صفحه یا هنگام ویرایش جزئیات یک کتاب، فصل یا قفسه اختصاص داد.',
|
||||
'attachments' => 'پیوست ها',
|
||||
'attachments_explain' => 'چند فایل را آپلود کنید یا چند پیوند را برای نمایش در صفحه خود ضمیمه کنید. اینها در نوار کناری صفحه قابل مشاهده هستند.',
|
||||
'attachments_explain_instant_save' => 'تغییرات در اینجا فورا ذخیره می شوند.',
|
||||
'attachments_items' => 'موارد پیوست شده',
|
||||
'attachments_upload' => 'آپلود فایل',
|
||||
'attachments_link' => 'پیوند را ضمیمه کنید',
|
||||
'attachments_set_link' => 'پیوند را تنظیم کنید',
|
||||
'attachments_delete' => 'آیا مطمئن هستید که می خواهید این پیوست را حذف کنید؟',
|
||||
'attachments_dropzone' => 'فایل ها را رها کنید یا برای پیوست کردن یک فایل اینجا را کلیک کنید',
|
||||
'attachments_no_files' => 'هیچ فایلی آپلود نشده است',
|
||||
'attachments_explain_link' => 'اگر ترجیح می دهید فایلی را آپلود نکنید، می توانید پیوندی را پیوست کنید. این می تواند پیوندی به صفحه دیگر یا پیوندی به فایلی در فضای ابری باشد.',
|
||||
'attachments_link_name' => 'نام پیوند',
|
||||
'attachment_link' => 'لینک پیوست',
|
||||
'attachments_link_url' => 'پیوند به فایل',
|
||||
'attachments_link_url_hint' => 'آدرس سایت یا فایل',
|
||||
'attach' => 'ضمیمه کنید',
|
||||
'attachments_insert_link' => 'پیوند پیوست را به صفحه اضافه کنید',
|
||||
'attachments_edit_file' => 'ویرایش فایل',
|
||||
'attachments_edit_file_name' => 'نام فایل',
|
||||
'attachments_edit_drop_upload' => 'فایل ها را رها کنید یا برای آپلود و بازنویسی اینجا کلیک کنید',
|
||||
'attachments_order_updated' => 'سفارش پیوست به روز شد',
|
||||
'attachments_updated_success' => 'جزئیات پیوست به روز شد',
|
||||
'attachments_deleted' => 'پیوست حذف شد',
|
||||
'attachments_file_uploaded' => 'فایل با موفقیت آپلود شد',
|
||||
'attachments_file_updated' => 'فایل با موفقیت به روز شد',
|
||||
'attachments_link_attached' => 'پیوند با موفقیت به صفحه پیوست شد',
|
||||
'templates' => 'قالب ها',
|
||||
'templates_set_as_template' => 'صفحه یک الگو است',
|
||||
'templates_explain_set_as_template' => 'می توانید این صفحه را به عنوان یک الگو تنظیم کنید تا از محتویات آن هنگام ایجاد صفحات دیگر استفاده شود. سایر کاربران در صورت داشتن مجوز مشاهده برای این صفحه می توانند از این الگو استفاده کنند.',
|
||||
'templates_replace_content' => 'محتوای صفحه را جایگزین کنید',
|
||||
'templates_append_content' => 'به محتوای صفحه اضافه کنید',
|
||||
'templates_prepend_content' => 'به محتوای صفحه اضافه کنید',
|
||||
'page_tags' => 'Page Tags',
|
||||
'chapter_tags' => 'Chapter Tags',
|
||||
'book_tags' => 'Book Tags',
|
||||
'shelf_tags' => 'Shelf Tags',
|
||||
'tag' => 'Tag',
|
||||
'tags' => 'Tags',
|
||||
'tag_name' => 'Tag Name',
|
||||
'tag_value' => 'Tag Value (Optional)',
|
||||
'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.",
|
||||
'tags_add' => 'Add another tag',
|
||||
'tags_remove' => 'Remove this tag',
|
||||
'tags_usages' => 'Total tag usages',
|
||||
'tags_assigned_pages' => 'Assigned to Pages',
|
||||
'tags_assigned_chapters' => 'Assigned to Chapters',
|
||||
'tags_assigned_books' => 'Assigned to Books',
|
||||
'tags_assigned_shelves' => 'Assigned to Shelves',
|
||||
'tags_x_unique_values' => ':count unique values',
|
||||
'tags_all_values' => 'All values',
|
||||
'tags_view_tags' => 'View Tags',
|
||||
'tags_view_existing_tags' => 'View existing tags',
|
||||
'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.',
|
||||
'attachments' => 'Attachments',
|
||||
'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.',
|
||||
'attachments_explain_instant_save' => 'Changes here are saved instantly.',
|
||||
'attachments_items' => 'Attached Items',
|
||||
'attachments_upload' => 'Upload File',
|
||||
'attachments_link' => 'Attach Link',
|
||||
'attachments_set_link' => 'Set Link',
|
||||
'attachments_delete' => 'Are you sure you want to delete this attachment?',
|
||||
'attachments_dropzone' => 'Drop files or click here to attach a file',
|
||||
'attachments_no_files' => 'No files have been uploaded',
|
||||
'attachments_explain_link' => 'You can attach a link if you\'d prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.',
|
||||
'attachments_link_name' => 'Link Name',
|
||||
'attachment_link' => 'Attachment link',
|
||||
'attachments_link_url' => 'Link to file',
|
||||
'attachments_link_url_hint' => 'Url of site or file',
|
||||
'attach' => 'Attach',
|
||||
'attachments_insert_link' => 'Add Attachment Link to Page',
|
||||
'attachments_edit_file' => 'Edit File',
|
||||
'attachments_edit_file_name' => 'File Name',
|
||||
'attachments_edit_drop_upload' => 'Drop files or click here to upload and overwrite',
|
||||
'attachments_order_updated' => 'Attachment order updated',
|
||||
'attachments_updated_success' => 'Attachment details updated',
|
||||
'attachments_deleted' => 'Attachment deleted',
|
||||
'attachments_file_uploaded' => 'File successfully uploaded',
|
||||
'attachments_file_updated' => 'File successfully updated',
|
||||
'attachments_link_attached' => 'Link successfully attached to page',
|
||||
'templates' => 'Templates',
|
||||
'templates_set_as_template' => 'Page is a template',
|
||||
'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.',
|
||||
'templates_replace_content' => 'Replace page content',
|
||||
'templates_append_content' => 'Append to page content',
|
||||
'templates_prepend_content' => 'Prepend to page content',
|
||||
|
||||
// Profile View
|
||||
'profile_user_for_x' => 'کاربر برای :time',
|
||||
'profile_created_content' => 'محتوا ایجاد کرد',
|
||||
'profile_not_created_pages' => ':userName هیچ صفحه ای ایجاد نکرده است',
|
||||
'profile_not_created_chapters' => ':userName هیچ فصلی ایجاد نکرده است',
|
||||
'profile_not_created_books' => ':userName هیچ کتابی ایجاد نکرده است',
|
||||
'profile_not_created_shelves' => ':userName هیچ قفسه ای ایجاد نکرده است',
|
||||
'profile_user_for_x' => 'User for :time',
|
||||
'profile_created_content' => 'Created Content',
|
||||
'profile_not_created_pages' => ':userName has not created any pages',
|
||||
'profile_not_created_chapters' => ':userName has not created any chapters',
|
||||
'profile_not_created_books' => ':userName has not created any books',
|
||||
'profile_not_created_shelves' => ':userName has not created any shelves',
|
||||
|
||||
// Comments
|
||||
'comment' => 'اظهار نظر',
|
||||
'comments' => 'نظرات',
|
||||
'comment_add' => 'افزودن توضیح',
|
||||
'comment_placeholder' => 'اینجا نظر بدهید',
|
||||
'comment_count' => '{0} بدون نظر|{1} 1 نظر|[2,*] :count نظرات',
|
||||
'comment_save' => 'ذخیره نظر',
|
||||
'comment_saving' => 'در حال ذخیره نظر...',
|
||||
'comment_deleting' => 'در حال حذف نظر...',
|
||||
'comment_new' => 'نظر جدید',
|
||||
'comment_created' => ':createDiff نظر داد',
|
||||
'comment_updated' => 'به روز رسانی :updateDiff توسط :username',
|
||||
'comment_deleted_success' => 'نظر حذف شد',
|
||||
'comment_created_success' => 'نظر اضافه شد',
|
||||
'comment_updated_success' => 'نظر به روز شد',
|
||||
'comment_delete_confirm' => 'آیا مطمئن هستید که می خواهید این نظر را حذف کنید؟',
|
||||
'comment_in_reply_to' => 'در پاسخ به :commentId',
|
||||
'comment' => 'Comment',
|
||||
'comments' => 'Comments',
|
||||
'comment_add' => 'Add Comment',
|
||||
'comment_placeholder' => 'Leave a comment here',
|
||||
'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
|
||||
'comment_save' => 'Save Comment',
|
||||
'comment_saving' => 'Saving comment...',
|
||||
'comment_deleting' => 'Deleting comment...',
|
||||
'comment_new' => 'New Comment',
|
||||
'comment_created' => 'commented :createDiff',
|
||||
'comment_updated' => 'Updated :updateDiff by :username',
|
||||
'comment_deleted_success' => 'Comment deleted',
|
||||
'comment_created_success' => 'Comment added',
|
||||
'comment_updated_success' => 'Comment updated',
|
||||
'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
|
||||
'comment_in_reply_to' => 'In reply to :commentId',
|
||||
|
||||
// Revision
|
||||
'revision_delete_confirm' => 'آیا مطمئن هستید که می خواهید این ویرایش را حذف کنید؟',
|
||||
'revision_restore_confirm' => 'آیا مطمئن هستید که می خواهید این ویرایش را بازیابی کنید؟ محتوای صفحه فعلی جایگزین خواهد شد.',
|
||||
'revision_delete_success' => 'ویرایش حذف شد',
|
||||
'revision_cannot_delete_latest' => 'نمی توان آخرین نسخه را حذف کرد.',
|
||||
'revision_delete_confirm' => 'Are you sure you want to delete this revision?',
|
||||
'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
|
||||
'revision_delete_success' => 'Revision deleted',
|
||||
'revision_cannot_delete_latest' => 'Cannot delete the latest revision.',
|
||||
|
||||
// Copy view
|
||||
'copy_consider' => 'لطفاً هنگام کپی کردن مطالب به موارد زیر توجه کنید.',
|
||||
'copy_consider_permissions' => 'تنظیمات مجوز سفارشی کپی نخواهد شد.',
|
||||
'copy_consider_owner' => 'شما مالک تمام محتوای کپی شده خواهید شد.',
|
||||
'copy_consider_images' => 'فایل های تصویر صفحه تکراری نخواهند شد و تصاویر اصلی ارتباط خود را با صفحه ای که در ابتدا در آن آپلود شده اند حفظ می کنند.',
|
||||
'copy_consider_attachments' => 'پیوست های صفحه کپی نمی شود.',
|
||||
'copy_consider_access' => 'تغییر مکان، مالک یا مجوزها ممکن است منجر به دسترسی به این محتوا برای افرادی شود که قبلاً به آنها دسترسی نداشتند.',
|
||||
'copy_consider' => 'Please consider the below when copying content.',
|
||||
'copy_consider_permissions' => 'Custom permission settings will not be copied.',
|
||||
'copy_consider_owner' => 'You will become the owner of all copied content.',
|
||||
'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.',
|
||||
'copy_consider_attachments' => 'Page attachments will not be copied.',
|
||||
'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.',
|
||||
];
|
||||
|
||||
@@ -23,10 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'آدرس داده ای برای این کاربر در داده های ارائه شده توسط سیستم احراز هویت خارجی یافت نشد',
|
||||
'saml_invalid_response_id' => 'درخواست از سیستم احراز هویت خارجی توسط فرایندی که توسط این نرم افزار آغاز شده است شناخته نمی شود. بازگشت به سیستم پس از ورود به سیستم می تواند باعث این مسئله شود.',
|
||||
'saml_fail_authed' => 'ورود به سیستم :system انجام نشد، سیستم مجوز موفقیت آمیز ارائه نکرد',
|
||||
'oidc_already_logged_in' => 'قبلا وارد شده اید',
|
||||
'oidc_user_not_registered' => 'کاربر :name ثبت نشده و ثبت نام خودکار غیرفعال است',
|
||||
'oidc_no_email_address' => 'آدرس ایمیلی برای این کاربر در داده های ارائه شده توسط سیستم احراز هویت خارجی یافت نشد',
|
||||
'oidc_fail_authed' => 'ورود به سیستم با استفاده از :system انجام نشد، سیستم مجوز موفقیت آمیز ارائه نکرد',
|
||||
'oidc_already_logged_in' => 'Already logged in',
|
||||
'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
|
||||
'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
|
||||
'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
|
||||
'social_no_action_defined' => 'عملی تعریف نشده است',
|
||||
'social_login_bad_response' => "خطای دریافت شده در هنگام ورود به سیستم:\n:error",
|
||||
'social_account_in_use' => 'این حساب :socialAccount از قبل در حال استفاده است، سعی کنید از طریق گزینه :socialAccount وارد سیستم شوید.',
|
||||
@@ -37,73 +37,73 @@ return [
|
||||
'social_account_register_instructions' => 'اگر هنوز حساب کاربری ندارید ، می توانید با استفاده از گزینه :socialAccount حساب خود را ثبت کنید.',
|
||||
'social_driver_not_found' => 'درایور شبکه اجتماعی یافت نشد',
|
||||
'social_driver_not_configured' => 'تنظیمات شبکه اجتماعی :socialAccount به درستی پیکربندی نشده است.',
|
||||
'invite_token_expired' => 'این پیوند دعوت منقضی شده است. در عوض می توانید سعی کنید رمز عبور حساب خود را بازنشانی کنید.',
|
||||
'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.',
|
||||
|
||||
// System
|
||||
'path_not_writable' => 'مسیر فایل :filePath را نمی توان در آن آپلود کرد. مطمئن شوید که روی سرور قابل نوشتن است.',
|
||||
'cannot_get_image_from_url' => 'نمی توان تصویر را از :url دریافت کرد',
|
||||
'cannot_create_thumbs' => 'سرور نمی تواند تصاویر کوچک ایجاد کند. لطفاً بررسی کنید که پسوند GD PHP را نصب کرده اید.',
|
||||
'server_upload_limit' => 'سرور اجازه آپلود در این اندازه را نمی دهد. لطفا اندازه فایل کوچکتر را امتحان کنید.',
|
||||
'uploaded' => 'سرور اجازه آپلود در این اندازه را نمی دهد. لطفا اندازه فایل کوچکتر را امتحان کنید.',
|
||||
'image_upload_error' => 'هنگام آپلود تصویر خطایی روی داد',
|
||||
'image_upload_type_error' => 'نوع تصویر در حال آپلود نامعتبر است',
|
||||
'file_upload_timeout' => 'زمان بارگذاری فایل به پایان رسیده است.',
|
||||
'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.',
|
||||
'cannot_get_image_from_url' => 'Cannot get image from :url',
|
||||
'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.',
|
||||
'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.',
|
||||
'uploaded' => 'The server does not allow uploads of this size. Please try a smaller file size.',
|
||||
'image_upload_error' => 'An error occurred uploading the image',
|
||||
'image_upload_type_error' => 'The image type being uploaded is invalid',
|
||||
'file_upload_timeout' => 'The file upload has timed out.',
|
||||
|
||||
// Attachments
|
||||
'attachment_not_found' => 'پیوست یافت نشد',
|
||||
'attachment_not_found' => 'Attachment not found',
|
||||
|
||||
// Pages
|
||||
'page_draft_autosave_fail' => 'پیش نویس ذخیره نشد. قبل از ذخیره این صفحه مطمئن شوید که به اینترنت متصل هستید',
|
||||
'page_custom_home_deletion' => 'وقتی صفحه ای به عنوان صفحه اصلی تنظیم شده است، نمی توان آن را حذف کرد',
|
||||
'page_draft_autosave_fail' => 'Failed to save draft. Ensure you have internet connection before saving this page',
|
||||
'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage',
|
||||
|
||||
// Entities
|
||||
'entity_not_found' => 'موجودیت یافت نشد',
|
||||
'bookshelf_not_found' => 'قفسه کتاب پیدا نشد',
|
||||
'book_not_found' => 'کتاب پیدا نشد',
|
||||
'page_not_found' => 'صفحه یافت نشد',
|
||||
'chapter_not_found' => 'فصل پیدا نشد',
|
||||
'selected_book_not_found' => 'کتاب انتخابی یافت نشد',
|
||||
'selected_book_chapter_not_found' => 'کتاب یا فصل انتخابی یافت نشد',
|
||||
'guests_cannot_save_drafts' => 'مهمانان نمی توانند پیش نویس ها را ذخیره کنند',
|
||||
'entity_not_found' => 'Entity not found',
|
||||
'bookshelf_not_found' => 'Bookshelf not found',
|
||||
'book_not_found' => 'Book not found',
|
||||
'page_not_found' => 'Page not found',
|
||||
'chapter_not_found' => 'Chapter not found',
|
||||
'selected_book_not_found' => 'The selected book was not found',
|
||||
'selected_book_chapter_not_found' => 'The selected Book or Chapter was not found',
|
||||
'guests_cannot_save_drafts' => 'Guests cannot save drafts',
|
||||
|
||||
// Users
|
||||
'users_cannot_delete_only_admin' => 'شما نمی توانید تنها ادمین را حذف کنید',
|
||||
'users_cannot_delete_guest' => 'شما نمی توانید کاربر مهمان را حذف کنید',
|
||||
'users_cannot_delete_only_admin' => 'You cannot delete the only admin',
|
||||
'users_cannot_delete_guest' => 'You cannot delete the guest user',
|
||||
|
||||
// Roles
|
||||
'role_cannot_be_edited' => 'این نقش قابل ویرایش نیست',
|
||||
'role_system_cannot_be_deleted' => 'این نقش یک نقش سیستمی است و قابل حذف نیست',
|
||||
'role_registration_default_cannot_delete' => 'این نقش در حالی که به عنوان نقش پیش فرض ثبت نام تنظیم شده است قابل حذف نیست',
|
||||
'role_cannot_remove_only_admin' => 'این کاربر تنها کاربری است که به نقش مدیر اختصاص داده شده است. قبل از تلاش برای حذف آن در اینجا، نقش مدیر را به کاربر دیگری اختصاص دهید.',
|
||||
'role_cannot_be_edited' => 'This role cannot be edited',
|
||||
'role_system_cannot_be_deleted' => 'This role is a system role and cannot be deleted',
|
||||
'role_registration_default_cannot_delete' => 'This role cannot be deleted while set as the default registration role',
|
||||
'role_cannot_remove_only_admin' => 'This user is the only user assigned to the administrator role. Assign the administrator role to another user before attempting to remove it here.',
|
||||
|
||||
// Comments
|
||||
'comment_list' => 'هنگام واکشی نظرات خطایی روی داد.',
|
||||
'cannot_add_comment_to_draft' => 'شما نمی توانید نظراتی را به یک پیش نویس اضافه کنید.',
|
||||
'comment_add' => 'هنگام افزودن/بهروزرسانی نظر خطایی روی داد.',
|
||||
'comment_delete' => 'هنگام حذف نظر خطایی روی داد.',
|
||||
'empty_comment' => 'نمی توان یک نظر خالی اضافه کرد.',
|
||||
'comment_list' => 'An error occurred while fetching the comments.',
|
||||
'cannot_add_comment_to_draft' => 'You cannot add comments to a draft.',
|
||||
'comment_add' => 'An error occurred while adding / updating the comment.',
|
||||
'comment_delete' => 'An error occurred while deleting the comment.',
|
||||
'empty_comment' => 'Cannot add an empty comment.',
|
||||
|
||||
// Error pages
|
||||
'404_page_not_found' => 'صفحه یافت نشد',
|
||||
'sorry_page_not_found' => 'با عرض پوزش، صفحه مورد نظر شما یافت نشد.',
|
||||
'sorry_page_not_found_permission_warning' => 'اگر انتظار داشتید این صفحه وجود داشته باشد، ممکن است اجازه مشاهده آن را نداشته باشید.',
|
||||
'image_not_found' => 'تصویر پیدا نشد',
|
||||
'image_not_found_subtitle' => 'با عرض پوزش، فایل تصویری که به دنبال آن بودید یافت نشد.',
|
||||
'image_not_found_details' => 'اگر انتظار داشتید این تصویر وجود داشته باشد، ممکن است حذف شده باشد.',
|
||||
'return_home' => 'بازگشت به خانه',
|
||||
'error_occurred' => 'خطایی رخ داد',
|
||||
'app_down' => ':appName در حال حاضر قطع است',
|
||||
'back_soon' => 'به زودی پشتیبان خواهد شد.',
|
||||
'404_page_not_found' => 'Page Not Found',
|
||||
'sorry_page_not_found' => 'Sorry, The page you were looking for could not be found.',
|
||||
'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.',
|
||||
'image_not_found' => 'Image Not Found',
|
||||
'image_not_found_subtitle' => 'Sorry, The image file you were looking for could not be found.',
|
||||
'image_not_found_details' => 'If you expected this image to exist it might have been deleted.',
|
||||
'return_home' => 'Return to home',
|
||||
'error_occurred' => 'An Error Occurred',
|
||||
'app_down' => ':appName is down right now',
|
||||
'back_soon' => 'It will be back up soon.',
|
||||
|
||||
// API errors
|
||||
'api_no_authorization_found' => 'هیچ نشانه مجوزی در درخواست یافت نشد',
|
||||
'api_bad_authorization_format' => 'یک نشانه مجوز در این درخواست یافت شد اما قالب نادرست به نظر میرسید',
|
||||
'api_user_token_not_found' => 'هیچ نشانه API منطبقی برای کد مجوز ارائه شده یافت نشد',
|
||||
'api_incorrect_token_secret' => 'راز ارائه شده برای کد API استفاده شده نادرست است',
|
||||
'api_user_no_api_permission' => 'مالک نشانه API استفاده شده اجازه برقراری تماس های API را ندارد',
|
||||
'api_user_token_expired' => 'رمز مجوز استفاده شده منقضی شده است',
|
||||
'api_no_authorization_found' => 'No authorization token found on the request',
|
||||
'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect',
|
||||
'api_user_token_not_found' => 'No matching API token was found for the provided authorization token',
|
||||
'api_incorrect_token_secret' => 'The secret provided for the given used API token is incorrect',
|
||||
'api_user_no_api_permission' => 'The owner of the used API token does not have permission to make API calls',
|
||||
'api_user_token_expired' => 'The authorization token used has expired',
|
||||
|
||||
// Settings & Maintenance
|
||||
'maintenance_test_email_failure' => 'خطا در هنگام ارسال ایمیل آزمایشی:',
|
||||
'maintenance_test_email_failure' => 'Error thrown when sending a test email:',
|
||||
|
||||
];
|
||||
|
||||
@@ -7,258 +7,258 @@
|
||||
return [
|
||||
|
||||
// Common Messages
|
||||
'settings' => 'تنظیمات',
|
||||
'settings_save' => 'تنظیمات را ذخیره کن',
|
||||
'settings_save_success' => 'تنظیمات ذخیره شد',
|
||||
'settings' => 'Settings',
|
||||
'settings_save' => 'Save Settings',
|
||||
'settings_save_success' => 'Settings saved',
|
||||
|
||||
// App Settings
|
||||
'app_customization' => 'سفارشی سازی',
|
||||
'app_features_security' => 'ویژگی ها و امنیت',
|
||||
'app_name' => 'نام نرم افزار',
|
||||
'app_name_desc' => 'این نام در هدر و در هر ایمیل ارسال شده توسط سیستم نشان داده شده است.',
|
||||
'app_name_header' => 'نمایش نام در هدر',
|
||||
'app_public_access' => 'دسترسی عمومی',
|
||||
'app_public_access_desc' => 'فعال کردن این گزینه به بازدیدکنندگانی که وارد سیستم نشدهاند اجازه میدهد تا به محتوای موجود در نمونه BookStack شما دسترسی داشته باشند.',
|
||||
'app_public_access_desc_guest' => 'دسترسی بازدیدکنندگان عمومی را می توان از طریق کاربر "مهمان" کنترل کرد.',
|
||||
'app_public_access_toggle' => 'اجازه دسترسی عمومی',
|
||||
'app_public_viewing' => 'مشاهده عمومی مجاز است؟',
|
||||
'app_secure_images' => 'آپلود تصویر با امنیت بالاتر',
|
||||
'app_secure_images_toggle' => 'آپلود تصویر با امنیت بالاتر',
|
||||
'app_secure_images_desc' => 'به دلایل عملکرد، همه تصاویر عمومی هستند. این گزینه یک رشته تصادفی و غیرقابل حدس زدن را در مقابل آدرس های تصویر اضافه می کند. برای جلوگیری از دسترسی آسان، اطمینان حاصل کنید که فهرست های دایرکتوری فعال نیستند.',
|
||||
'app_editor' => 'ویرایشگر صفحه',
|
||||
'app_editor_desc' => 'انتخاب کنید کدام ویرایشگر توسط همه کاربران برای ویرایش صفحات استفاده شود.',
|
||||
'app_custom_html' => 'محتوای اصلی HTML سفارشی',
|
||||
'app_custom_html_desc' => 'هر محتوای اضافه شده در اینجا در پایین بخش <head> هر صفحه درج می شود. این برای تغییر سبک ها یا اضافه کردن کد تجزیه و تحلیل مفید است.',
|
||||
'app_custom_html_disabled_notice' => 'محتوای سر HTML سفارشی در این صفحه تنظیمات غیرفعال است تا اطمینان حاصل شود که هر گونه تغییر شکسته می تواند برگردانده شود.',
|
||||
'app_logo' => 'لوگوی برنامه',
|
||||
'app_logo_desc' => 'این تصویر باید 43 پیکسل ارتفاع داشته باشد. <br>تصاویر بزرگ کوچک می شوند.',
|
||||
'app_primary_color' => 'رنگ اصلی برنامه',
|
||||
'app_primary_color_desc' => 'رنگ اصلی برنامه را از جمله بنر، دکمه ها و پیوندها تنظیم می کند.',
|
||||
'app_homepage' => 'صفحه اصلی برنامه',
|
||||
'app_homepage_desc' => 'به جای نمای پیشفرض، یک نمای را برای نمایش در صفحه اصلی انتخاب کنید. مجوزهای صفحه برای صفحات انتخابی نادیده گرفته می شود.',
|
||||
'app_homepage_select' => 'یک صفحه را انتخاب کنید',
|
||||
'app_footer_links' => 'پیوندهای پاورقی',
|
||||
'app_footer_links_desc' => 'پیوندهایی را برای نمایش در پاورقی سایت اضافه کنید. اینها در پایین اکثر صفحات نمایش داده می شوند، از جمله صفحاتی که نیازی به ورود به سیستم ندارند. می توانید از برچسب "trans::<key>" برای استفاده از ترجمه های تعریف شده توسط سیستم استفاده کنید. به عنوان مثال: با استفاده از "trans::common.privacy_policy" متن ترجمه شده "خط مشی رازداری" و "trans::common.terms_of_service" متن ترجمه شده "شرایط خدمات" را ارائه می دهد.',
|
||||
'app_footer_links_label' => 'برچسب پیوند',
|
||||
'app_footer_links_url' => 'لینک URL',
|
||||
'app_footer_links_add' => 'پیوند پاورقی را اضافه کنید',
|
||||
'app_disable_comments' => 'غیرفعال کردن نظرات',
|
||||
'app_disable_comments_toggle' => 'نظرات را غیرفعال کنید',
|
||||
'app_disable_comments_desc' => 'نظرات را در تمام صفحات برنامه غیرفعال می کند. <br> نظرات موجود نشان داده نمی شوند.',
|
||||
'app_customization' => 'Customization',
|
||||
'app_features_security' => 'Features & Security',
|
||||
'app_name' => 'Application Name',
|
||||
'app_name_desc' => 'This name is shown in the header and in any system-sent emails.',
|
||||
'app_name_header' => 'Show name in header',
|
||||
'app_public_access' => 'Public Access',
|
||||
'app_public_access_desc' => 'Enabling this option will allow visitors, that are not logged-in, to access content in your BookStack instance.',
|
||||
'app_public_access_desc_guest' => 'Access for public visitors can be controlled through the "Guest" user.',
|
||||
'app_public_access_toggle' => 'Allow public access',
|
||||
'app_public_viewing' => 'Allow public viewing?',
|
||||
'app_secure_images' => 'Higher Security Image Uploads',
|
||||
'app_secure_images_toggle' => 'Enable higher security image uploads',
|
||||
'app_secure_images_desc' => 'For performance reasons, all images are public. This option adds a random, hard-to-guess string in front of image urls. Ensure directory indexes are not enabled to prevent easy access.',
|
||||
'app_editor' => 'Page Editor',
|
||||
'app_editor_desc' => 'Select which editor will be used by all users to edit pages.',
|
||||
'app_custom_html' => 'Custom HTML Head Content',
|
||||
'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the <head> section of every page. This is handy for overriding styles or adding analytics code.',
|
||||
'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.',
|
||||
'app_logo' => 'Application Logo',
|
||||
'app_logo_desc' => 'This image should be 43px in height. <br>Large images will be scaled down.',
|
||||
'app_primary_color' => 'Application Primary Color',
|
||||
'app_primary_color_desc' => 'Sets the primary color for the application including the banner, buttons, and links.',
|
||||
'app_homepage' => 'Application Homepage',
|
||||
'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
|
||||
'app_homepage_select' => 'Select a page',
|
||||
'app_footer_links' => 'Footer Links',
|
||||
'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
|
||||
'app_footer_links_label' => 'Link Label',
|
||||
'app_footer_links_url' => 'Link URL',
|
||||
'app_footer_links_add' => 'Add Footer Link',
|
||||
'app_disable_comments' => 'Disable Comments',
|
||||
'app_disable_comments_toggle' => 'Disable comments',
|
||||
'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.',
|
||||
|
||||
// Color settings
|
||||
'content_colors' => 'رنگ های محتوا',
|
||||
'content_colors_desc' => 'رنگ ها را برای همه عناصر در سلسله مراتب سازمان صفحه تنظیم می کند. برای خوانایی، انتخاب رنگ هایی با روشنایی مشابه با رنگ های پیش فرض توصیه می شود.',
|
||||
'bookshelf_color' => 'رنگ قفسه',
|
||||
'book_color' => 'رنگ کتاب',
|
||||
'chapter_color' => 'رنگ فصل',
|
||||
'page_color' => 'رنگ صفحه',
|
||||
'page_draft_color' => 'رنگ پیش نویس صفحه',
|
||||
'content_colors' => 'Content Colors',
|
||||
'content_colors_desc' => 'Sets colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.',
|
||||
'bookshelf_color' => 'Shelf Color',
|
||||
'book_color' => 'Book Color',
|
||||
'chapter_color' => 'Chapter Color',
|
||||
'page_color' => 'Page Color',
|
||||
'page_draft_color' => 'Page Draft Color',
|
||||
|
||||
// Registration Settings
|
||||
'reg_settings' => 'ثبت نام',
|
||||
'reg_enable' => 'فعال کردن ثبت نام',
|
||||
'reg_enable_toggle' => 'فعال کردن ثبت نام',
|
||||
'reg_enable_desc' => 'هنگامی که ثبت نام فعال باشد، کاربر می تواند خود را به عنوان کاربر برنامه ثبت نام کند. پس از ثبت نام به آنها یک نقش کاربر پیش فرض داده می شود.',
|
||||
'reg_default_role' => 'نقش کاربر پیش فرض پس از ثبت نام',
|
||||
'reg_enable_external_warning' => 'هنگامی که احراز هویت خارجی LDAP یا SAML فعال است، گزینه بالا نادیده گرفته می شود. در صورتی که احراز هویت، در برابر سیستم خارجی در حال استفاده، موفقیت آمیز باشد، حساب های کاربری برای اعضای غیر موجود به طور خودکار ایجاد می شود.',
|
||||
'reg_email_confirmation' => 'تایید ایمیل',
|
||||
'reg_email_confirmation_toggle' => 'نیاز به تایید ایمیل',
|
||||
'reg_confirm_email_desc' => 'در صورت استفاده از محدودیت دامنه، تایید ایمیل مورد نیاز است و این گزینه نادیده گرفته می شود.',
|
||||
'reg_confirm_restrict_domain' => 'محدودیت دامنه',
|
||||
'reg_confirm_restrict_domain_desc' => 'فهرستی از دامنههای ایمیل جدا شده با کاما را وارد کنید که میخواهید ثبت نام را محدود کنید. قبل از اینکه به کاربران اجازه تعامل با برنامه داده شود، ایمیلی برای تأیید آدرس آنها ارسال می شود. <br> توجه داشته باشید که کاربران پس از ثبت نام موفق می توانند آدرس ایمیل خود را تغییر دهند.',
|
||||
'reg_confirm_restrict_domain_placeholder' => 'بدون محدودیت',
|
||||
'reg_settings' => 'Registration',
|
||||
'reg_enable' => 'Enable Registration',
|
||||
'reg_enable_toggle' => 'Enable registration',
|
||||
'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.',
|
||||
'reg_default_role' => 'Default user role after registration',
|
||||
'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.',
|
||||
'reg_email_confirmation' => 'Email Confirmation',
|
||||
'reg_email_confirmation_toggle' => 'Require email confirmation',
|
||||
'reg_confirm_email_desc' => 'If domain restriction is used then email confirmation will be required and this option will be ignored.',
|
||||
'reg_confirm_restrict_domain' => 'Domain Restriction',
|
||||
'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application. <br> Note that users will be able to change their email addresses after successful registration.',
|
||||
'reg_confirm_restrict_domain_placeholder' => 'No restriction set',
|
||||
|
||||
// Maintenance settings
|
||||
'maint' => 'نگهداری',
|
||||
'maint_image_cleanup' => 'پاکسازی تصاویر',
|
||||
'maint_image_cleanup_desc' => 'محتوای صفحه و بازبینی را اسکن میکند تا بررسی کند که کدام تصاویر و نقاشیها در حال حاضر استفاده میشوند و کدام تصاویر اضافی هستند. قبل از اجرای این کار، مطمئن شوید که یک پایگاه داده کامل و یک نسخه پشتیبان از تصویر ایجاد کرده اید.',
|
||||
'maint_delete_images_only_in_revisions' => 'همچنین تصاویری را که فقط در ویرایش های صفحه قدیمی وجود دارند حذف کنید',
|
||||
'maint_image_cleanup_run' => 'پاکسازی را اجرا کنید',
|
||||
'maint_image_cleanup_warning' => ':count تصاویر بالقوه استفاده نشده پیدا شد. آیا مطمئن هستید که می خواهید این تصاویر را حذف کنید؟',
|
||||
'maint_image_cleanup_success' => ':count تصویر بالقوه استفاده نشده پیدا و حذف شد!',
|
||||
'maint_image_cleanup_nothing_found' => 'هیچ تصویر استفاده نشده ای یافت نشد، چیزی حذف نشد!',
|
||||
'maint_send_test_email' => 'یک ایمیل آزمایشی ارسال کنید',
|
||||
'maint_send_test_email_desc' => 'این یک ایمیل آزمایشی به آدرس ایمیل شما مشخص شده در نمایه شما ارسال می کند.',
|
||||
'maint_send_test_email_run' => 'ارسال ایمیل آزمایشی',
|
||||
'maint_send_test_email_success' => 'ایمیل به آدرس :address ارسال شد',
|
||||
'maint_send_test_email_mail_subject' => 'تست ایمیل',
|
||||
'maint_send_test_email_mail_greeting' => 'به نظر می رسد تحویل ایمیل کار می کند!',
|
||||
'maint_send_test_email_mail_text' => 'تبریک می گویم! با دریافت این اعلان ایمیل، به نظر می رسد تنظیمات ایمیل شما به درستی پیکربندی شده است.',
|
||||
'maint_recycle_bin_desc' => 'قفسهها، کتابها، فصلها و صفحات حذفشده به سطل بازیافت فرستاده میشوند تا بتوان آنها را بازیابی کرد یا برای همیشه حذف کرد. بسته به پیکربندی سیستم، اقلام قدیمی در سطل بازیافت ممکن است پس از مدتی به طور خودکار حذف شوند.',
|
||||
'maint_recycle_bin_open' => 'سطل بازیافت را باز کنید',
|
||||
'maint' => 'Maintenance',
|
||||
'maint_image_cleanup' => 'Cleanup Images',
|
||||
'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.',
|
||||
'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions',
|
||||
'maint_image_cleanup_run' => 'Run Cleanup',
|
||||
'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?',
|
||||
'maint_image_cleanup_success' => ':count potentially unused images found and deleted!',
|
||||
'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!',
|
||||
'maint_send_test_email' => 'Send a Test Email',
|
||||
'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.',
|
||||
'maint_send_test_email_run' => 'Send test email',
|
||||
'maint_send_test_email_success' => 'Email sent to :address',
|
||||
'maint_send_test_email_mail_subject' => 'Test Email',
|
||||
'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
|
||||
'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
|
||||
'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
|
||||
'maint_recycle_bin_open' => 'Open Recycle Bin',
|
||||
|
||||
// Recycle Bin
|
||||
'recycle_bin' => 'سطل زباله',
|
||||
'recycle_bin_desc' => 'در اینجا می توانید مواردی را که حذف شده اند بازیابی کنید یا حذف دائمی آنها را از سیستم انتخاب کنید. این لیست برخلاف لیستهای فعالیت مشابه در سیستمی که فیلترهای مجوز اعمال میشوند، فیلتر نشده است.',
|
||||
'recycle_bin_deleted_item' => 'مورد حذف شده',
|
||||
'recycle_bin_deleted_parent' => 'والد',
|
||||
'recycle_bin_deleted_by' => 'حذف شده توسط',
|
||||
'recycle_bin_deleted_at' => 'زمان حذف',
|
||||
'recycle_bin_permanently_delete' => 'برای همیشه حذف کنید',
|
||||
'recycle_bin_restore' => 'بازگرداندن',
|
||||
'recycle_bin_contents_empty' => 'سطل بازیافت در حال حاضر خالی است',
|
||||
'recycle_bin_empty' => 'سطل آشغال خالی',
|
||||
'recycle_bin_empty_confirm' => 'این کار همه اقلام موجود در سطل بازیافت از جمله محتوای موجود در هر مورد را برای همیشه از بین می برد. آیا مطمئن هستید که می خواهید سطل بازیافت را خالی کنید؟',
|
||||
'recycle_bin_destroy_confirm' => 'این اقدام این مورد را به همراه هر عنصر فرعی فهرست شده در زیر برای همیشه از سیستم حذف می کند و شما نمی توانید این محتوا را بازیابی کنید. آیا مطمئن هستید که می خواهید این مورد را برای همیشه حذف کنید؟',
|
||||
'recycle_bin_destroy_list' => 'مواردی که باید نابود شوند',
|
||||
'recycle_bin_restore_list' => 'مواردی که باید بازیابی شوند',
|
||||
'recycle_bin_restore_confirm' => 'این اقدام، مورد حذف شده، از جمله هر عنصر فرزند، را به مکان اصلی خود باز می گرداند. اگر مکان اصلی از آن زمان حذف شده باشد، و اکنون در سطل بازیافت است، مورد اصلی نیز باید بازیابی شود.',
|
||||
'recycle_bin_restore_deleted_parent' => 'والد این مورد نیز حذف شده است. تا زمانی که آن والد نیز بازیابی نشود، این موارد حذف خواهند شد.',
|
||||
'recycle_bin_restore_parent' => 'بازیابی والد',
|
||||
'recycle_bin_destroy_notification' => ':count تعداد از کل اقلام از سطل بازیافت حذف شده.',
|
||||
'recycle_bin_restore_notification' => ':count تعداد از کل اقلام از سطل بازیافت بازیابی شده.',
|
||||
'recycle_bin' => 'Recycle Bin',
|
||||
'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
|
||||
'recycle_bin_deleted_item' => 'Deleted Item',
|
||||
'recycle_bin_deleted_parent' => 'Parent',
|
||||
'recycle_bin_deleted_by' => 'Deleted By',
|
||||
'recycle_bin_deleted_at' => 'Deletion Time',
|
||||
'recycle_bin_permanently_delete' => 'Permanently Delete',
|
||||
'recycle_bin_restore' => 'Restore',
|
||||
'recycle_bin_contents_empty' => 'The recycle bin is currently empty',
|
||||
'recycle_bin_empty' => 'Empty Recycle Bin',
|
||||
'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?',
|
||||
'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
|
||||
'recycle_bin_destroy_list' => 'Items to be Destroyed',
|
||||
'recycle_bin_restore_list' => 'Items to be Restored',
|
||||
'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
|
||||
'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
|
||||
'recycle_bin_restore_parent' => 'Restore Parent',
|
||||
'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
|
||||
'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
|
||||
|
||||
// Audit Log
|
||||
'audit' => 'گزارش حسابرسی',
|
||||
'audit_desc' => 'این گزارش حسابرسی لیستی از فعالیت های ردیابی شده در سیستم را نمایش می دهد. این لیست برخلاف لیستهای فعالیت مشابه در سیستمی که فیلترهای مجوز اعمال میشوند، فیلتر نشده است.',
|
||||
'audit_event_filter' => 'فیلتر رویداد',
|
||||
'audit_event_filter_no_filter' => 'بدون فیلتر',
|
||||
'audit_deleted_item' => 'مورد حذف شده',
|
||||
'audit_deleted_item_name' => 'نام :name',
|
||||
'audit_table_user' => 'نام كاربري',
|
||||
'audit_table_event' => 'رویداد',
|
||||
'audit_table_related' => 'مورد یا جزئیات مرتبط',
|
||||
'audit_table_ip' => 'آدرس IP',
|
||||
'audit_table_date' => 'تاریخهای فعالیت',
|
||||
'audit_date_from' => 'محدوده تاریخ از',
|
||||
'audit_date_to' => 'محدوده تاریخ تا',
|
||||
'audit' => 'Audit Log',
|
||||
'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
|
||||
'audit_event_filter' => 'Event Filter',
|
||||
'audit_event_filter_no_filter' => 'No Filter',
|
||||
'audit_deleted_item' => 'Deleted Item',
|
||||
'audit_deleted_item_name' => 'Name: :name',
|
||||
'audit_table_user' => 'User',
|
||||
'audit_table_event' => 'Event',
|
||||
'audit_table_related' => 'Related Item or Detail',
|
||||
'audit_table_ip' => 'IP Address',
|
||||
'audit_table_date' => 'Activity Date',
|
||||
'audit_date_from' => 'Date Range From',
|
||||
'audit_date_to' => 'Date Range To',
|
||||
|
||||
// Role Settings
|
||||
'roles' => 'نقش ها',
|
||||
'role_user_roles' => 'نقش های کاربر',
|
||||
'role_create' => 'نقش جدید ایجاد کنید',
|
||||
'role_create_success' => 'نقش با موفقیت ایجاد شد',
|
||||
'role_delete' => 'حذف نقش',
|
||||
'role_delete_confirm' => 'با این کار نقش با نام \':roleName\' حذف می شود.',
|
||||
'role_delete_users_assigned' => 'این نقش دارای :userCount کاربرانی است که به آن اختصاص داده شده است. اگر می خواهید کاربران را از این نقش مهاجرت کنید، نقش جدیدی را در زیر انتخاب کنید.',
|
||||
'role_delete_no_migration' => "کاربران را منتقل نکنید",
|
||||
'role_delete_sure' => 'آیا مطمئنید که می خواهید این نقش را حذف کنید؟',
|
||||
'role_delete_success' => 'نقش با موفقیت حذف شد',
|
||||
'role_edit' => 'ویرایش نقش',
|
||||
'role_details' => 'جزئیات نقش',
|
||||
'role_name' => 'اسم نقش',
|
||||
'role_desc' => 'شرح کوتاه نقش',
|
||||
'role_mfa_enforced' => 'به احراز هویت چند عاملی نیاز دارد',
|
||||
'role_external_auth_id' => 'شناسه های تأیید هویت خارجی',
|
||||
'role_system' => 'مجوزهای سیستم',
|
||||
'role_manage_users' => 'مدیریت کاربران',
|
||||
'role_manage_roles' => 'نقش ها و مجوزهای نقش را مدیریت کنید',
|
||||
'role_manage_entity_permissions' => 'تمام مجوزهای کتاب، فصل و صفحه را مدیریت کنید',
|
||||
'role_manage_own_entity_permissions' => 'مجوزها را در کتاب، فصل و صفحات خود مدیریت کنید',
|
||||
'role_manage_page_templates' => 'مدیریت قالب های صفحه',
|
||||
'role_access_api' => 'دسترسی به API سیستم',
|
||||
'role_manage_settings' => 'تنظیمات برنامه را مدیریت کنید',
|
||||
'role_export_content' => 'صادرات محتوا',
|
||||
'role_asset' => 'مجوزهای دارایی',
|
||||
'roles_system_warning' => 'توجه داشته باشید که دسترسی به هر یک از سه مجوز فوق میتواند به کاربر اجازه دهد تا امتیازات خود یا امتیازات دیگران را در سیستم تغییر دهد. فقط نقش هایی را با این مجوزها به کاربران مورد اعتماد اختصاص دهید.',
|
||||
'role_asset_desc' => 'این مجوزها دسترسی پیشفرض به داراییهای درون سیستم را کنترل میکنند. مجوزهای مربوط به کتابها، فصلها و صفحات این مجوزها را لغو میکنند.',
|
||||
'role_asset_admins' => 'به ادمینها بهطور خودکار به همه محتوا دسترسی داده میشود، اما این گزینهها ممکن است گزینههای UI را نشان داده یا پنهان کنند.',
|
||||
'role_all' => 'همه',
|
||||
'role_own' => 'صاحب',
|
||||
'role_controlled_by_asset' => 'توسط دارایی که در آن آپلود می شود کنترل می شود',
|
||||
'role_save' => 'ذخیره نقش',
|
||||
'role_update_success' => 'نقش با موفقیت به روز شد',
|
||||
'role_users' => 'کاربران در این نقش',
|
||||
'role_users_none' => 'در حال حاضر هیچ کاربری به این نقش اختصاص داده نشده است',
|
||||
'roles' => 'Roles',
|
||||
'role_user_roles' => 'User Roles',
|
||||
'role_create' => 'Create New Role',
|
||||
'role_create_success' => 'Role successfully created',
|
||||
'role_delete' => 'Delete Role',
|
||||
'role_delete_confirm' => 'This will delete the role with the name \':roleName\'.',
|
||||
'role_delete_users_assigned' => 'This role has :userCount users assigned to it. If you would like to migrate the users from this role select a new role below.',
|
||||
'role_delete_no_migration' => "Don't migrate users",
|
||||
'role_delete_sure' => 'Are you sure you want to delete this role?',
|
||||
'role_delete_success' => 'Role successfully deleted',
|
||||
'role_edit' => 'Edit Role',
|
||||
'role_details' => 'Role Details',
|
||||
'role_name' => 'Role Name',
|
||||
'role_desc' => 'Short Description of Role',
|
||||
'role_mfa_enforced' => 'Requires Multi-Factor Authentication',
|
||||
'role_external_auth_id' => 'External Authentication IDs',
|
||||
'role_system' => 'System Permissions',
|
||||
'role_manage_users' => 'Manage users',
|
||||
'role_manage_roles' => 'Manage roles & role permissions',
|
||||
'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions',
|
||||
'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages',
|
||||
'role_manage_page_templates' => 'Manage page templates',
|
||||
'role_access_api' => 'Access system API',
|
||||
'role_manage_settings' => 'Manage app settings',
|
||||
'role_export_content' => 'Export content',
|
||||
'role_asset' => 'Asset Permissions',
|
||||
'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.',
|
||||
'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.',
|
||||
'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.',
|
||||
'role_all' => 'All',
|
||||
'role_own' => 'Own',
|
||||
'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to',
|
||||
'role_save' => 'Save Role',
|
||||
'role_update_success' => 'Role successfully updated',
|
||||
'role_users' => 'Users in this role',
|
||||
'role_users_none' => 'No users are currently assigned to this role',
|
||||
|
||||
// Users
|
||||
'users' => 'کاربران',
|
||||
'user_profile' => 'پرونده کاربر',
|
||||
'users_add_new' => 'افزودن کاربر جدید',
|
||||
'users_search' => 'جستجوی کاربران',
|
||||
'users_latest_activity' => 'آخرین فعالیت',
|
||||
'users_details' => 'جزئیات کاربر',
|
||||
'users_details_desc' => 'یک نام نمایشی و یک آدرس ایمیل برای این کاربر تنظیم کنید. آدرس ایمیل برای ورود به برنامه استفاده خواهد شد.',
|
||||
'users_details_desc_no_email' => 'یک نام نمایشی برای این کاربر تنظیم کنید تا دیگران بتوانند آنها را تشخیص دهند.',
|
||||
'users_role' => 'نقش های کاربر',
|
||||
'users_role_desc' => 'انتخاب کنید که این کاربر به کدام نقش ها اختصاص داده شود. اگر یک کاربر به چندین نقش اختصاص داده شود، مجوزهای آن نقشها روی هم قرار میگیرند و تمام تواناییهای نقشهای اختصاص داده شده را دریافت خواهند کرد.',
|
||||
'users_password' => 'رمز عبور كاربر',
|
||||
'users_password_desc' => 'رمز عبوری را که برای ورود به برنامه استفاده می شود تنظیم کنید. این باید حداقل 8 کاراکتر باشد.',
|
||||
'users_send_invite_text' => 'می توانید انتخاب کنید که برای این کاربر یک ایمیل دعوت نامه ارسال شود که به آنها امکان می دهد رمز عبور خود را تعیین کنند در غیر این صورت می توانید رمز عبور خود را تعیین کنید.',
|
||||
'users_send_invite_option' => 'ارسال ایمیل دعوت کاربر',
|
||||
'users_external_auth_id' => 'شناسه احراز هویت خارجی',
|
||||
'users_external_auth_id_desc' => 'این شناسه ای است که برای مطابقت با این کاربر هنگام برقراری ارتباط با سیستم احراز هویت خارجی شما استفاده می شود.',
|
||||
'users_password_warning' => 'فقط در صورتی که مایل به تغییر رمز عبور خود هستید، موارد زیر را پر کنید.',
|
||||
'users_system_public' => 'این کاربر نماینده هر کاربر مهمانی است که از نمونه شما بازدید می کند. نمی توان از آن برای ورود استفاده کرد اما به طور خودکار اختصاص داده می شود.',
|
||||
'users_delete' => 'حذف کاربر',
|
||||
'users_delete_named' => 'حذف :userName',
|
||||
'users_delete_warning' => 'با این کار این کاربر با نام \':userName\' به طور کامل از سیستم حذف می شود.',
|
||||
'users_delete_confirm' => 'آیا مطمئن هستید که می خواهید این کاربر را حذف کنید؟',
|
||||
'users_migrate_ownership' => 'انتقال مالکیت',
|
||||
'users_migrate_ownership_desc' => 'اگر میخواهید کاربر دیگری مالک همه مواردی باشد که در حال حاضر متعلق به این کاربر است، کاربری را در اینجا انتخاب کنید.',
|
||||
'users_none_selected' => 'هیچ کاربری انتخاب نشد',
|
||||
'users_delete_success' => 'کاربر با موفقیت حذف شد',
|
||||
'users_edit' => 'ویرایش کاربر',
|
||||
'users_edit_profile' => 'ویرایش پروفایل',
|
||||
'users_edit_success' => 'کاربر با موفقیت به روز شد',
|
||||
'users_avatar' => 'آواتار کاربر',
|
||||
'users_avatar_desc' => 'تصویری را برای نشان دادن این کاربر انتخاب کنید. این باید تقریباً 256 پیکسل مربع باشد.',
|
||||
'users_preferred_language' => 'زبان ترجیحی',
|
||||
'users_preferred_language_desc' => 'این گزینه زبان مورد استفاده برای رابط کاربری برنامه را تغییر می دهد. این روی محتوای ایجاد شده توسط کاربر تأثیری نخواهد داشت.',
|
||||
'users_social_accounts' => 'حساب های اجتماعی',
|
||||
'users_social_accounts_info' => 'در اینجا میتوانید حسابهای دیگر خود را برای ورود سریعتر و آسانتر متصل کنید. قطع ارتباط حساب در اینجا، دسترسی مجاز قبلی را لغو نمی کند. دسترسی را از تنظیمات نمایه خود در حساب اجتماعی متصل لغو کنید.',
|
||||
'users_social_connect' => 'اتصال حساب کاربری',
|
||||
'users_social_disconnect' => 'قطع حساب',
|
||||
'users_social_connected' => 'حساب :socialAccount با موفقیت به نمایه شما پیوست شد.',
|
||||
'users_social_disconnected' => 'حساب :socialAccount با موفقیت از نمایه شما قطع شد.',
|
||||
'users_api_tokens' => 'توکنهای API',
|
||||
'users_api_tokens_none' => 'هیچ نشانه API برای این کاربر ایجاد نشده است',
|
||||
'users_api_tokens_create' => 'ایجاد توکن',
|
||||
'users_api_tokens_expires' => 'منقضی شده ها',
|
||||
'users_api_tokens_docs' => 'مستندات API',
|
||||
'users_mfa' => 'احراز هویت چند عاملی',
|
||||
'users_mfa_desc' => 'تنظیم احراز هویت چند مرحله ای یک لایه امنیتی دیگر به حساب شما اضافه میکند.',
|
||||
'users_mfa_x_methods' => ':count روش پیکربندی شده است|:count روش های پیکربندی شده',
|
||||
'users_mfa_configure' => 'روش پیکربندی',
|
||||
'users' => 'Users',
|
||||
'user_profile' => 'User Profile',
|
||||
'users_add_new' => 'Add New User',
|
||||
'users_search' => 'Search Users',
|
||||
'users_latest_activity' => 'Latest Activity',
|
||||
'users_details' => 'User Details',
|
||||
'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.',
|
||||
'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.',
|
||||
'users_role' => 'User Roles',
|
||||
'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
|
||||
'users_password' => 'User Password',
|
||||
'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.',
|
||||
'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
|
||||
'users_send_invite_option' => 'Send user invite email',
|
||||
'users_external_auth_id' => 'External Authentication ID',
|
||||
'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your external authentication system.',
|
||||
'users_password_warning' => 'Only fill the below if you would like to change your password.',
|
||||
'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.',
|
||||
'users_delete' => 'Delete User',
|
||||
'users_delete_named' => 'Delete user :userName',
|
||||
'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.',
|
||||
'users_delete_confirm' => 'Are you sure you want to delete this user?',
|
||||
'users_migrate_ownership' => 'Migrate Ownership',
|
||||
'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.',
|
||||
'users_none_selected' => 'No user selected',
|
||||
'users_delete_success' => 'User successfully removed',
|
||||
'users_edit' => 'Edit User',
|
||||
'users_edit_profile' => 'Edit Profile',
|
||||
'users_edit_success' => 'User successfully updated',
|
||||
'users_avatar' => 'User Avatar',
|
||||
'users_avatar_desc' => 'Select an image to represent this user. This should be approx 256px square.',
|
||||
'users_preferred_language' => 'Preferred Language',
|
||||
'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.',
|
||||
'users_social_accounts' => 'Social Accounts',
|
||||
'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.',
|
||||
'users_social_connect' => 'Connect Account',
|
||||
'users_social_disconnect' => 'Disconnect Account',
|
||||
'users_social_connected' => ':socialAccount account was successfully attached to your profile.',
|
||||
'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.',
|
||||
'users_api_tokens' => 'API Tokens',
|
||||
'users_api_tokens_none' => 'No API tokens have been created for this user',
|
||||
'users_api_tokens_create' => 'Create Token',
|
||||
'users_api_tokens_expires' => 'Expires',
|
||||
'users_api_tokens_docs' => 'API Documentation',
|
||||
'users_mfa' => 'Multi-Factor Authentication',
|
||||
'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
|
||||
'users_mfa_x_methods' => ':count method configured|:count methods configured',
|
||||
'users_mfa_configure' => 'Configure Methods',
|
||||
|
||||
// API Tokens
|
||||
'user_api_token_create' => 'ایجاد توکن API',
|
||||
'user_api_token_name' => 'نام',
|
||||
'user_api_token_name_desc' => 'توکن خود را به عنوان یادآوری هدف مورد نظر در آینده، نامی خوانا بدهید.',
|
||||
'user_api_token_expiry' => 'تاریخ انقضا',
|
||||
'user_api_token_expiry_desc' => 'تاریخی را تعیین کنید که در آن این توکن منقضی شود. پس از این تاریخ، درخواستهایی که با استفاده از این رمز انجام میشوند دیگر کار نمیکنند. خالی گذاشتن این فیلد باعث انقضای 100 سال آینده می شود.',
|
||||
'user_api_token_create_secret_message' => 'بلافاصله پس از ایجاد این توکن یک "شناسه رمز" و "رمز رمز" تولید و نمایش داده می شود. راز فقط یک بار نشان داده میشود، بنابراین قبل از ادامه، حتماً مقدار را در جایی امن و مطمئن کپی کنید.',
|
||||
'user_api_token_create_success' => 'توکن API با موفقیت ایجاد شد',
|
||||
'user_api_token_update_success' => 'توکن API با موفقیت به روز شد',
|
||||
'user_api_token' => 'توکن API',
|
||||
'user_api_token_id' => 'شناسه توکن',
|
||||
'user_api_token_id_desc' => 'این یک شناسه غیرقابل ویرایش است که برای این نشانه ایجاد شده است که باید در درخواستهای API ارائه شود.',
|
||||
'user_api_token_secret' => 'رمز توکن',
|
||||
'user_api_token_secret_desc' => 'این یک راز ایجاد شده توسط سیستم برای این نشانه است که باید در درخواست های API ارائه شود. این فقط یک بار نمایش داده می شود، بنابراین این مقدار را در جایی امن و مطمئن کپی کنید.',
|
||||
'user_api_token_created' => 'توکن ایجاد شد :timeAgo',
|
||||
'user_api_token_updated' => 'توکن به روز شد :timeAgo',
|
||||
'user_api_token_delete' => 'توکن را حذف کنید',
|
||||
'user_api_token_delete_warning' => 'با این کار این نشانه API با نام \':tokenName\' به طور کامل از سیستم حذف می شود.',
|
||||
'user_api_token_delete_confirm' => 'آیا مطمئن هستید که می خواهید این نشانه API را حذف کنید؟',
|
||||
'user_api_token_delete_success' => 'توکن API با موفقیت حذف شد',
|
||||
'user_api_token_create' => 'Create API Token',
|
||||
'user_api_token_name' => 'Name',
|
||||
'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.',
|
||||
'user_api_token_expiry' => 'Expiry Date',
|
||||
'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.',
|
||||
'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.',
|
||||
'user_api_token_create_success' => 'API token successfully created',
|
||||
'user_api_token_update_success' => 'API token successfully updated',
|
||||
'user_api_token' => 'API Token',
|
||||
'user_api_token_id' => 'Token ID',
|
||||
'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.',
|
||||
'user_api_token_secret' => 'Token Secret',
|
||||
'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.',
|
||||
'user_api_token_created' => 'Token created :timeAgo',
|
||||
'user_api_token_updated' => 'Token updated :timeAgo',
|
||||
'user_api_token_delete' => 'Delete Token',
|
||||
'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.',
|
||||
'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
|
||||
'user_api_token_delete_success' => 'API token successfully deleted',
|
||||
|
||||
// Webhooks
|
||||
'webhooks' => 'وب هوک ها',
|
||||
'webhooks_create' => 'ایجاد وب هوک جدید',
|
||||
'webhooks_none_created' => 'هنوز هیچ وب هوکی ایجاد نشده است.',
|
||||
'webhooks_edit' => 'ویرایش وب هوک',
|
||||
'webhooks_save' => 'ذخیره وب هوک',
|
||||
'webhooks_details' => 'جزئیات وب هوک',
|
||||
'webhooks_details_desc' => 'یک نام کاربر پسند و یک نقطه پایانی POST به عنوان مکانی برای ارسال داده های وب هوک ارائه دهید.',
|
||||
'webhooks_events' => 'رویدادهای وب هوک',
|
||||
'webhooks_events_desc' => 'تمام رویدادهایی را که باید باعث فراخوانی این وب هوک شوند، انتخاب کنید.',
|
||||
'webhooks_events_warning' => 'به خاطر داشته باشید که این رویدادها برای همه رویدادهای انتخابی فعال خواهند شد، حتی اگر مجوزهای سفارشی اعمال شوند. مطمئن شوید که استفاده از این وب هوک محتوای محرمانه را فاش نمی کند.',
|
||||
'webhooks_events_all' => 'تمام رویدادهای سیستم',
|
||||
'webhooks_name' => 'نام وب هوک',
|
||||
'webhooks_timeout' => 'مهلت درخواست وب هوک (ثانیه)',
|
||||
'webhooks_endpoint' => 'نقطه پایانی وب هوک',
|
||||
'webhooks_active' => 'وب هوک فعال',
|
||||
'webhook_events_table_header' => 'رویدادها',
|
||||
'webhooks_delete' => 'حذف وب هوک',
|
||||
'webhooks_delete_warning' => 'با این کار این وب هوک با نام \':webhookName\' به طور کامل از سیستم حذف می شود.',
|
||||
'webhooks_delete_confirm' => 'آیا مطمئن هستید که می خواهید این وب هوک را حذف کنید؟',
|
||||
'webhooks_format_example' => 'نمونه قالب وب هوک',
|
||||
'webhooks_format_example_desc' => 'دادههای وب هوک بهعنوان یک درخواست POST به نقطه پایانی پیکربندیشده بهعنوان JSON با فرمت زیر ارسال میشوند. ویژگی های "related_item" و "url" اختیاری هستند و به نوع رویداد راه اندازی شده بستگی دارد.',
|
||||
'webhooks_status' => 'وضعیت وب هوک',
|
||||
'webhooks_last_called' => 'آخرین تماس:',
|
||||
'webhooks_last_errored' => 'آخرین خطا:',
|
||||
'webhooks_last_error_message' => 'آخرین پیغام خطا:',
|
||||
'webhooks' => 'Webhooks',
|
||||
'webhooks_create' => 'Create New Webhook',
|
||||
'webhooks_none_created' => 'No webhooks have yet been created.',
|
||||
'webhooks_edit' => 'Edit Webhook',
|
||||
'webhooks_save' => 'Save Webhook',
|
||||
'webhooks_details' => 'Webhook Details',
|
||||
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
|
||||
'webhooks_events' => 'Webhook Events',
|
||||
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
|
||||
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
|
||||
'webhooks_events_all' => 'All system events',
|
||||
'webhooks_name' => 'Webhook Name',
|
||||
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
|
||||
'webhooks_endpoint' => 'Webhook Endpoint',
|
||||
'webhooks_active' => 'Webhook Active',
|
||||
'webhook_events_table_header' => 'Events',
|
||||
'webhooks_delete' => 'Delete Webhook',
|
||||
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
|
||||
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
|
||||
'webhooks_format_example' => 'Webhook Format Example',
|
||||
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
|
||||
'webhooks_status' => 'Webhook Status',
|
||||
'webhooks_last_called' => 'Last Called:',
|
||||
'webhooks_last_errored' => 'Last Errored:',
|
||||
'webhooks_last_error_message' => 'Last Error Message:',
|
||||
|
||||
|
||||
//! If editing translations files directly please ignore this in all
|
||||
|
||||
@@ -15,7 +15,7 @@ return [
|
||||
'alpha_dash' => ':attribute باید فقط حروف الفبا، اعداد، خط تیره و زیرخط باشد.',
|
||||
'alpha_num' => ':attribute باید فقط حروف الفبا و اعداد باشد.',
|
||||
'array' => ':attribute باید آرایه باشد.',
|
||||
'backup_codes' => 'کد ارائه شده معتبر نیست یا قبلا استفاده شده است.',
|
||||
'backup_codes' => 'The provided code is not valid or has already been used.',
|
||||
'before' => ':attribute باید تاریخی قبل از :date باشد.',
|
||||
'between' => [
|
||||
'numeric' => ':attribute باید بین :min و :max باشد.',
|
||||
@@ -99,7 +99,7 @@ return [
|
||||
],
|
||||
'string' => 'فیلد :attribute باید متن باشد.',
|
||||
'timezone' => 'فیلد :attribute باید یک منطقه زمانی معتبر باشد.',
|
||||
'totp' => 'کد ارائه شده معتبر نیست یا منقضی شده است.',
|
||||
'totp' => 'The provided code is not valid or has expired.',
|
||||
'unique' => ':attribute قبلا انتخاب شده است.',
|
||||
'url' => ':attribute معتبر نمیباشد.',
|
||||
'uploaded' => 'بارگذاری فایل :attribute موفقیت آمیز نبود.',
|
||||
|
||||
@@ -74,8 +74,7 @@ return [
|
||||
'status' => 'Statut',
|
||||
'status_active' => 'Actif',
|
||||
'status_inactive' => 'Inactif',
|
||||
'never' => 'Jamais',
|
||||
'none' => 'None',
|
||||
'never' => 'Never',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Développer le menu',
|
||||
|
||||
@@ -240,24 +240,24 @@ return [
|
||||
'webhooks_edit' => 'Éditer le Webhook',
|
||||
'webhooks_save' => 'Enregistrer le Webhook',
|
||||
'webhooks_details' => 'Détails du Webhook',
|
||||
'webhooks_details_desc' => 'Renseignez un nom ainsi que votre endpoint POST sur lequel les données du webhook doivent être envoyées.',
|
||||
'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.',
|
||||
'webhooks_events' => 'Événements du Webhook',
|
||||
'webhooks_events_desc' => 'Sélectionnez tous les évènements qui doivent déclencher un appel sur ce webhook.',
|
||||
'webhooks_events_warning' => 'Gardez à l\'esprit que ces événements seront déclenchés pour chaque événement sélectionné, même si des permissions personnalisées sont appliquées. Vérifiez bien que l\'utilisation de ce webhook n\'exposera pas de contenu confidentiel.',
|
||||
'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.',
|
||||
'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.',
|
||||
'webhooks_events_all' => 'Tous les événements système',
|
||||
'webhooks_name' => 'Nom du Webhook',
|
||||
'webhooks_timeout' => 'Délai d\'expiration de requête du Webhook (en secondes)',
|
||||
'webhooks_timeout' => 'Webhook Request Timeout (Seconds)',
|
||||
'webhooks_endpoint' => 'Point de terminaison du Webhook',
|
||||
'webhooks_active' => 'Webhook actif',
|
||||
'webhook_events_table_header' => 'Événements',
|
||||
'webhooks_delete' => 'Supprimer le Webhook',
|
||||
'webhooks_delete_warning' => 'Ceci supprimera complètement du système le webhook ayant le nom \':webhookName\'.',
|
||||
'webhooks_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer ce webhook ?',
|
||||
'webhooks_format_example' => 'Exemple de Format de Webhook',
|
||||
'webhooks_format_example_desc' => 'Les données du webhook sont envoyées dans une requête POST vers l\'endpoint au format JSON respectant le format ci-dessous. Les propriétés "related_item" et "url" sont optionnelles et dépendront du type d\'événement déclenché.',
|
||||
'webhooks_status' => 'Statut du webhook',
|
||||
'webhooks_last_called' => 'Dernier appel :',
|
||||
'webhooks_last_errored' => 'Dernier en erreur :',
|
||||
'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.',
|
||||
'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?',
|
||||
'webhooks_format_example' => 'Webhook Format Example',
|
||||
'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.',
|
||||
'webhooks_status' => 'Webhook Status',
|
||||
'webhooks_last_called' => 'Last Called:',
|
||||
'webhooks_last_errored' => 'Last Errored:',
|
||||
'webhooks_last_error_message' => 'Dernier message d\'erreur : ',
|
||||
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expand Header Menu',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Proširi izbornik',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user