Compare commits

..

33 Commits

Author SHA1 Message Date
Dan Brown
d34f837e19 Started work on details/summary blocks 2022-01-21 17:07:27 +00:00
Dan Brown
264966de02 Crawled forward slightly on table resizing 2022-01-21 12:16:05 +00:00
Dan Brown
8b4f112462 Improved iframe embed interaction within editor 2022-01-20 13:55:44 +00:00
Dan Brown
20f37292a1 Added support for iframe node blocks 2022-01-20 13:38:16 +00:00
Dan Brown
b1f5495a7f Shared link mark update logic with color controls 2022-01-19 23:54:59 +00:00
Dan Brown
bb12541179 Improved anchor updating/remove action
Now will update the link mark if you have a no-range selection on the
link.
2022-01-19 23:22:48 +00:00
Dan Brown
e3ead1c115 Added radio options for anchor target option 2022-01-19 22:14:09 +00:00
Dan Brown
9b4ea368dc Started on table editing/resizing 2022-01-19 16:46:45 +00:00
Dan Brown
4b08eef12c Added table creation and insertion 2022-01-19 15:22:10 +00:00
Dan Brown
b2283106fc Added source code view/set button 2022-01-19 11:31:02 +00:00
Dan Brown
7125530e55 Added image resizing via drag handles 2022-01-17 17:43:16 +00:00
Dan Brown
7622106665 Added jsdoc types for prosemirror
Also added link markdown handling when target is set.
2022-01-16 15:21:57 +00:00
Dan Brown
89194a3f85 Got link insert/editor working 2022-01-16 14:37:58 +00:00
Dan Brown
7703face52 Started menu dialog support 2022-01-14 20:56:05 +00:00
Dan Brown
c013d7e549 Added inline code and clear formatting 2022-01-14 18:27:37 +00:00
Dan Brown
07c8876e22 Imported marks from example schema for customization 2022-01-14 14:55:07 +00:00
Dan Brown
0dc64d22ef Added horizonal rule insert 2022-01-14 14:33:37 +00:00
Dan Brown
013943dcc5 Added list buttons 2022-01-14 13:14:25 +00:00
Dan Brown
dc1c9807ef Reorganised & aligned editor icons 2022-01-12 16:10:16 +00:00
Dan Brown
56d7864bdf Added bg-color mark, added color grid selectors 2022-01-12 15:33:59 +00:00
Dan Brown
1018b5627e Added text color mark 2022-01-12 11:02:28 +00:00
Dan Brown
717557df89 Rolled out text alignment to other block types
Completed off alignment types and markdown handling in the process.
2022-01-12 10:18:06 +00:00
Dan Brown
6744ab2ff9 Got alignment buttons barely working for paragraphs 2022-01-11 18:58:24 +00:00
Dan Brown
4e5153d372 Copied in default node types for control and future editing 2022-01-11 17:13:40 +00:00
Dan Brown
34db138a64 Split marks and nodes into their own files 2022-01-11 16:26:12 +00:00
Dan Brown
c3595b1807 Added strike, sup and sub marks 2022-01-11 16:00:57 +00:00
Dan Brown
a8f48185b5 Got underline working in editor
Major step, since this is the first inline HTML element which needed
advanced parsing out on the markdown side, since not commonmark
supported.
2022-01-10 13:38:32 +00:00
Dan Brown
9d7174557e Added in a custom menubar
This is a copy of the ProseMirror/prosemirror-menu repo files
which suggest working from a fork of this.

These changes include the ability to select callouts
from the menubar.
2022-01-09 16:37:16 +00:00
Dan Brown
47c3d4fc0f Fixed issue with new nodes being callouts 2022-01-07 21:56:04 +00:00
Dan Brown
81dfe9c345 Got callouts about working, simplified markdown setup 2022-01-07 21:22:07 +00:00
Dan Brown
0fb8ba00a5 Attempted adding tricky custom block
Attempted adding callouts, which have the challenge of being shown via
HTML within markdown content. Got stuck on parsing back to the state
from markdown.
2022-01-07 16:37:36 +00:00
Dan Brown
aa9fe9ca82 Added notes file 2022-01-07 13:36:53 +00:00
Dan Brown
27f9e8e4bd Started playing with prosemirror
- Got base setup together with WYSIWYG/Markdown switching, where HTML is
  the base content format.
- Added some testing routes/views for initial development.
- Added some dev npm tasks to support editor-specific actions.
2022-01-07 13:36:52 +00:00
195 changed files with 6231 additions and 2200 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -3,10 +3,10 @@ name: phpstan
on:
push:
branches-ignore:
- l10n_development
- l10n_master
pull_request:
branches-ignore:
- l10n_development
- l10n_master
jobs:
build:

View File

@@ -3,10 +3,10 @@ name: phpunit
on:
push:
branches-ignore:
- l10n_development
- l10n_master
pull_request:
branches-ignore:
- l10n_development
- l10n_master
jobs:
build:

View File

@@ -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
View File

@@ -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
View 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.

View File

@@ -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'])) {

View File

@@ -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';
});
}

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -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' => [],
],

View File

@@ -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;
}
}

View File

@@ -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, '/'));
}

View File

@@ -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

View File

@@ -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;
}
/**

View File

@@ -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);

View File

@@ -109,35 +109,15 @@ class PageContent
/**
* Convert all inline base64 content to uploaded image files.
* Regex is used to locate the start of data-uri definitions then
* manual looping over content is done to parse the whole data uri.
* Attempting to capture the whole data uri using regex can cause PHP
* PCRE limits to be hit with larger, multi-MB, files.
*/
protected function extractBase64ImagesFromMarkdown(string $markdown)
{
$matches = [];
$contentLength = strlen($markdown);
$replacements = [];
preg_match_all('/!\[.*?]\(.*?(data:image\/.{1,6};base64,)/', $markdown, $matches, PREG_OFFSET_CAPTURE);
preg_match_all('/!\[.*?]\(.*?(data:image\/.*?)[)"\s]/', $markdown, $matches);
foreach ($matches[1] as $base64MatchPair) {
[$dataUri, $index] = $base64MatchPair;
for ($i = strlen($dataUri) + $index; $i < $contentLength; $i++) {
$char = $markdown[$i];
if ($char === ')' || $char === ' ' || $char === "\n" || $char === '"') {
break;
}
$dataUri .= $char;
}
$newUrl = $this->base64ImageUriToUploadedImageUrl($dataUri);
$replacements[] = [$dataUri, $newUrl];
}
foreach ($replacements as [$dataUri, $newUrl]) {
$markdown = str_replace($dataUri, $newUrl, $markdown);
foreach ($matches[1] as $base64Match) {
$newUrl = $this->base64ImageUriToUploadedImageUrl($base64Match);
$markdown = str_replace($base64Match, $newUrl, $markdown);
}
return $markdown;

View File

@@ -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;
}
}

View File

@@ -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();
}
/**

View File

@@ -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,
]);
}

View File

@@ -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);

View File

@@ -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));
}
}

View File

@@ -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);
}
/**

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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 */

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,7 +1,7 @@
# BookStack
[![GitHub release](https://img.shields.io/github/release/BookStackApp/BookStack.svg)](https://github.com/BookStackApp/BookStack/releases/latest)
[![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/development/LICENSE)
[![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/master/LICENSE)
[![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack)
[![Discord](https://img.shields.io/static/v1?label=chat&message=discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
[![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](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:

View File

@@ -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');
}
}

View File

@@ -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);
}

View File

@@ -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 = "[![" + (selectedText || image.name) + "](" + imageUrl + ")](" + image.url + ")";
let newText = "[![" + (selectedText || image.name) + "](" + image.thumbs.display + ")](" + image.url + ")";
this.cm.focus();
this.cm.replaceSelection(newText);
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);

View File

@@ -136,14 +136,18 @@ function codePlugin() {
const selectedNode = editor.selection.getNode();
if (!elemIsCodeBlock(selectedNode)) {
const providedCode = editor.selection.getContent({format: 'text'});
const providedCode = editor.selection.getNode().textContent;
window.components.first('code-editor').open(providedCode, '', (code, lang) => {
const wrap = document.createElement('div');
wrap.innerHTML = `<pre><code class="language-${lang}"></code></pre>`;
wrap.querySelector('code').innerText = code;
editor.insertContent(wrap.innerHTML);
editor.focus();
editor.formatter.toggle('pre');
const node = editor.selection.getNode();
editor.dom.setHTML(node, wrap.querySelector('pre').innerHTML);
editor.fire('SetContent');
editor.focus()
});
return;
}
@@ -559,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');
@@ -720,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
View 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()
});

View 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;

View 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;

View 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;
}
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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)
}

View 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.

View 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;

View 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;

View 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;

View 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);
}

View 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)
}

View 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
}

View 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;

View 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;

View 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;

View 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;

View 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';
}
}

View 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)
}

View 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;

View 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;

View 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;

View 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
View 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);
}
}

View File

@@ -75,7 +75,6 @@ return [
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
'none' => 'None',
// Header
'header_menu_expand' => 'عرض القائمة',

View File

@@ -32,7 +32,6 @@ return [
'digits_between' => 'يجب أن يكون :attribute بعدد خانات بين :min و :max.',
'email' => 'يجب أن يكون :attribute عنوان بريد إلكتروني صالح.',
'ends_with' => 'يجب أن تنتهي السمة بأحد القيم التالية',
'file' => 'The :attribute must be provided as a valid file.',
'filled' => 'حقل :attribute مطلوب.',
'gt' => [
'numeric' => 'يجب أن تكون السمة أكبر من: القيمة.',

View File

@@ -75,7 +75,6 @@ return [
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
'none' => 'None',
// Header
'header_menu_expand' => 'Expand Header Menu',

View File

@@ -32,7 +32,6 @@ return [
'digits_between' => ':attribute трябва да бъде с дължина между :min и :max цифри.',
'email' => ':attribute трябва да бъде валиден имейл адрес.',
'ends_with' => ':attribute трябва да свършва с един от следните символи: :values',
'file' => 'The :attribute must be provided as a valid file.',
'filled' => 'Полето :attribute е задължителен.',
'gt' => [
'numeric' => ':attribute трябва да бъде по-голям от :value.',

View File

@@ -75,7 +75,6 @@ return [
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
'none' => 'None',
// Header
'header_menu_expand' => 'Otvori meni u zaglavlju',

View File

@@ -32,7 +32,6 @@ return [
'digits_between' => ':attribute mora imati između :min i :max brojeva.',
'email' => ':attribute mora biti ispravna e-mail adresa.',
'ends_with' => ':attribute mora završavati sa jednom od sljedećih: :values',
'file' => 'The :attribute must be provided as a valid file.',
'filled' => 'Polje :attribute je obavezno.',
'gt' => [
'numeric' => ':attribute mora biti veći od :value.',

View File

@@ -75,7 +75,6 @@ return [
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
'none' => 'None',
// Header
'header_menu_expand' => 'Expand Header Menu',

View File

@@ -32,7 +32,6 @@ return [
'digits_between' => 'El camp :attribute ha de tenir entre :min i :max dígits.',
'email' => 'El camp :attribute ha de ser una adreça electrònica vàlida.',
'ends_with' => 'El camp :attribute ha d\'acabar amb un dels següents valors: :values',
'file' => 'The :attribute must be provided as a valid file.',
'filled' => 'El camp :attribute és obligatori.',
'gt' => [
'numeric' => 'El camp :attribute ha de ser més gran que :value.',

View File

@@ -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í',

View File

@@ -32,7 +32,6 @@ return [
'digits_between' => ':attribute musí být dlouhé nejméně :min a nejvíce :max pozic.',
'email' => ':attribute není platný formát.',
'ends_with' => ':attribute musí končit jednou z následujících hodnot: :values',
'file' => 'The :attribute must be provided as a valid file.',
'filled' => ':attribute musí být vyplněno.',
'gt' => [
'numeric' => ':attribute musí být větší než :value.',

View File

@@ -75,7 +75,6 @@ return [
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
'none' => 'None',
// Header
'header_menu_expand' => 'Udvid header menu',

View File

@@ -32,7 +32,6 @@ return [
'digits_between' => ':attribute skal være mellem :min og :max cifre.',
'email' => ':attribute skal være en gyldig mail-adresse.',
'ends_with' => ':attribute skal slutte på en af følgende værdier: :values',
'file' => 'The :attribute must be provided as a valid file.',
'filled' => ':attribute er obligatorisk.',
'gt' => [
'numeric' => ':attribute skal være større end :value.',

View File

@@ -75,7 +75,6 @@ return [
'status_active' => 'Aktiv',
'status_inactive' => 'Inaktiv',
'never' => 'Never',
'none' => 'None',
// Header
'header_menu_expand' => 'Header-Menü erweitern',

View File

@@ -32,7 +32,6 @@ return [
'digits_between' => ':attribute muss zwischen :min und :max Stellen haben.',
'email' => ':attribute muss eine valide E-Mail-Adresse sein.',
'ends_with' => ':attribute muss mit einem der folgenden Werte: :values enden',
'file' => 'The :attribute must be provided as a valid file.',
'filled' => ':attribute ist erforderlich.',
'gt' => [
'numeric' => ':attribute muss größer als :value sein.',

View File

@@ -75,7 +75,6 @@ return [
'status_active' => 'Aktiv',
'status_inactive' => 'Inaktiv',
'never' => 'Never',
'none' => 'None',
// Header
'header_menu_expand' => 'Header-Menü erweitern',

View File

@@ -32,7 +32,6 @@ return [
'digits_between' => ':attribute muss zwischen :min und :max Stellen haben.',
'email' => ':attribute muss eine valide E-Mail-Adresse sein.',
'ends_with' => ':attribute muss mit einem der folgenden Werte: :values enden',
'file' => 'The :attribute must be provided as a valid file.',
'filled' => ':attribute ist erforderlich.',
'gt' => [
'numeric' => ':attribute muss größer als :value sein.',

View File

@@ -75,7 +75,6 @@ return [
'status_active' => 'Active',
'status_inactive' => 'Inactive',
'never' => 'Never',
'none' => 'None',
// Header
'header_menu_expand' => 'Expand Header Menu',

View File

@@ -32,7 +32,6 @@ return [
'digits_between' => 'The :attribute must be between :min and :max digits.',
'email' => 'The :attribute must be a valid email address.',
'ends_with' => 'The :attribute must end with one of the following: :values',
'file' => 'The :attribute must be provided as a valid file.',
'filled' => 'The :attribute field is required.',
'gt' => [
'numeric' => 'The :attribute must be greater than :value.',

View File

@@ -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',

View File

@@ -32,7 +32,6 @@ return [
'digits_between' => ':attribute debe ser un valor entre :min y :max dígios.',
'email' => ':attribute debe ser un correo electrónico válido.',
'ends_with' => 'El :attribute debe terminar con uno de los siguientes: :values',
'file' => 'The :attribute must be provided as a valid file.',
'filled' => 'El campo :attribute es requerido.',
'gt' => [
'numeric' => 'El :attribute debe ser mayor que :value.',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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.',
];

View File

@@ -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:',

View File

@@ -32,7 +32,6 @@ return [
'digits_between' => ':attribute debe ser un valor entre :min y :max dígios.',
'email' => ':attribute debe ser una dirección álida.',
'ends_with' => 'El :attribute debe terminar con uno de los siguientes: :values',
'file' => 'The :attribute must be provided as a valid file.',
'filled' => 'El campo :attribute es requerido.',
'gt' => [
'numeric' => 'El :attribute debe ser mayor que :value.',

View File

@@ -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!',

View File

@@ -75,7 +75,6 @@ return [
'status_active' => 'Aktiivne',
'status_inactive' => 'Mitteaktiivne',
'never' => 'Mitte kunagi',
'none' => 'None',
// Header
'header_menu_expand' => 'Laienda päisemenüü',

Some files were not shown because too many files have changed in this diff Show More