mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-19 11:19:38 +03:00
Compare commits
33 Commits
v21.12.5
...
prosemirro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d34f837e19 | ||
|
|
264966de02 | ||
|
|
8b4f112462 | ||
|
|
20f37292a1 | ||
|
|
b1f5495a7f | ||
|
|
bb12541179 | ||
|
|
e3ead1c115 | ||
|
|
9b4ea368dc | ||
|
|
4b08eef12c | ||
|
|
b2283106fc | ||
|
|
7125530e55 | ||
|
|
7622106665 | ||
|
|
89194a3f85 | ||
|
|
7703face52 | ||
|
|
c013d7e549 | ||
|
|
07c8876e22 | ||
|
|
0dc64d22ef | ||
|
|
013943dcc5 | ||
|
|
dc1c9807ef | ||
|
|
56d7864bdf | ||
|
|
1018b5627e | ||
|
|
717557df89 | ||
|
|
6744ab2ff9 | ||
|
|
4e5153d372 | ||
|
|
34db138a64 | ||
|
|
c3595b1807 | ||
|
|
a8f48185b5 | ||
|
|
9d7174557e | ||
|
|
47c3d4fc0f | ||
|
|
81dfe9c345 | ||
|
|
0fb8ba00a5 | ||
|
|
aa9fe9ca82 | ||
|
|
27f9e8e4bd |
@@ -297,11 +297,6 @@ RECYCLE_BIN_LIFETIME=30
|
||||
# Maximum file size, in megabytes, that can be uploaded to the system.
|
||||
FILE_UPLOAD_SIZE_LIMIT=50
|
||||
|
||||
# Export Page Size
|
||||
# Primarily used to determine page size of PDF exports.
|
||||
# Can be 'a4' or 'letter'.
|
||||
EXPORT_PAGE_SIZE=a4
|
||||
|
||||
# Allow <script> tags in page content
|
||||
# Note, if set to 'true' the page editor may still escape scripts.
|
||||
ALLOW_CONTENT_SCRIPTS=false
|
||||
|
||||
5
.github/translators.txt
vendored
5
.github/translators.txt
vendored
@@ -210,8 +210,3 @@ Tomáš Batelka (Vofy) :: Czech
|
||||
Mundo Racional (ismael.mesquita) :: Portuguese, Brazilian
|
||||
Zarik (3apuk) :: Russian
|
||||
Ali Shaatani (a.shaatani) :: Arabic
|
||||
ChacMaster :: Portuguese, Brazilian
|
||||
Saeed (saeed205) :: Persian
|
||||
Julesdevops :: French
|
||||
peter cerny (posli.to.semka) :: Slovak
|
||||
Pavel Karlin (pavelkarlin) :: Russian
|
||||
|
||||
4
.github/workflows/phpstan.yml
vendored
4
.github/workflows/phpstan.yml
vendored
@@ -3,10 +3,10 @@ name: phpstan
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
4
.github/workflows/phpunit.yml
vendored
4
.github/workflows/phpunit.yml
vendored
@@ -3,10 +3,10 @@ name: phpunit
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
4
.github/workflows/test-migrations.yml
vendored
4
.github/workflows/test-migrations.yml
vendored
@@ -3,10 +3,10 @@ name: test-migrations
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
- l10n_master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,10 +5,10 @@ Homestead.yaml
|
||||
.idea
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/public/dist/*.map
|
||||
/public/dist
|
||||
/public/plugins
|
||||
/public/css/*.map
|
||||
/public/js/*.map
|
||||
/public/css
|
||||
/public/js
|
||||
/public/bower
|
||||
/public/build/
|
||||
/storage/images
|
||||
|
||||
49
TODO
Normal file
49
TODO
Normal file
@@ -0,0 +1,49 @@
|
||||
### Next
|
||||
|
||||
- Table cell height resize & cell width resize via width style
|
||||
- Column resize source: https://github.com/ProseMirror/prosemirror-tables/blob/master/src/columnresizing.js
|
||||
- Have updated column resizing to set cell widths
|
||||
- Now need to handle table overall size on change, then heights.
|
||||
|
||||
- Details/Summary
|
||||
- Need view to control summary editability, make readonly but editable via popover.
|
||||
- Need some default styles to visualise details boundary.
|
||||
- Markdown parser needs to be updated to handle separate open/close tags for blocks.
|
||||
|
||||
### In-Progress
|
||||
|
||||
- Tables
|
||||
- Details/Summary
|
||||
|
||||
### Features
|
||||
|
||||
- Images
|
||||
- Drawings
|
||||
- LTR/RTL control
|
||||
- Fullscreen
|
||||
- Paste Image Uploading
|
||||
- Drag + Drop Image Uploading
|
||||
- Checkbox/TODO list items
|
||||
- Code blocks
|
||||
- Indents
|
||||
- Attachment integration (Drag & drop)
|
||||
- Template system integration.
|
||||
|
||||
### Improvements
|
||||
|
||||
- List type changing.
|
||||
- Color picker options should have "clear" option.
|
||||
- Color picker buttons should be split, with button to re-apply last selected color.
|
||||
- Color picker options should change color if different instead of remove.
|
||||
- Clear formatting, If no selection range, clear the formatting of parent block.
|
||||
- If no marks, clear the block type if text type?
|
||||
- Remove links button? (Action already in place if link href is empty).
|
||||
- Links - Validate URL.
|
||||
- Links - Integrate entity picker.
|
||||
- iFrame - Parse iframe HTML & auto-convert youtube/vimeo urls to embeds.
|
||||
|
||||
### Notes
|
||||
|
||||
- Use NodeViews for embedded content (Code, Drawings) where control is needed.
|
||||
- Probably still easiest to have seperate (codemirror) MD editor. Can alter display output via NodeViews to make MD like
|
||||
but its tricky since editing the markdown content would change the block definition/type while editing.
|
||||
@@ -60,11 +60,8 @@ class OidcJwtSigningKey
|
||||
*/
|
||||
protected function loadFromJwkArray(array $jwk)
|
||||
{
|
||||
// 'alg' is optional for a JWK, but we will still attempt to validate if
|
||||
// it exists otherwise presume it will be compatible.
|
||||
$alg = $jwk['alg'] ?? null;
|
||||
if ($jwk['kty'] !== 'RSA' || !(is_null($alg) || $alg === 'RS256')) {
|
||||
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
|
||||
if ($jwk['alg'] !== 'RS256') {
|
||||
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}");
|
||||
}
|
||||
|
||||
if (empty($jwk['use'])) {
|
||||
|
||||
@@ -164,9 +164,7 @@ class OidcProviderSettings
|
||||
protected function filterKeys(array $keys): array
|
||||
{
|
||||
return array_filter($keys, function (array $key) {
|
||||
$alg = $key['alg'] ?? null;
|
||||
|
||||
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
|
||||
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
*/
|
||||
public function attachDefaultRole(): void
|
||||
{
|
||||
$roleId = intval(setting('registration-role'));
|
||||
$roleId = setting('registration-role');
|
||||
if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
|
||||
$this->roles()->attach($roleId);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
* Configuration should be altered via the `.env` file or environment variables.
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
$dompdfPaperSizeMap = [
|
||||
'a4' => 'a4',
|
||||
'letter' => 'letter',
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
@@ -154,7 +150,7 @@ return [
|
||||
*
|
||||
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
|
||||
*/
|
||||
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
|
||||
'default_paper_size' => 'a4',
|
||||
|
||||
/**
|
||||
* The default font family.
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
* Configuration should be altered via the `.env` file or environment variables.
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
$snappyPaperSizeMap = [
|
||||
'a4' => 'A4',
|
||||
'letter' => 'Letter',
|
||||
];
|
||||
|
||||
return [
|
||||
'pdf' => [
|
||||
@@ -18,8 +14,7 @@ return [
|
||||
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
|
||||
'timeout' => false,
|
||||
'options' => [
|
||||
'outline' => true,
|
||||
'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
|
||||
'outline' => true,
|
||||
],
|
||||
'env' => [],
|
||||
],
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\Rules\Unique;
|
||||
use Symfony\Component\Console\Command\Command as SymfonyCommand;
|
||||
@@ -21,8 +19,7 @@ class CreateAdmin extends Command
|
||||
protected $signature = 'bookstack:create-admin
|
||||
{--email= : The email address for the new admin user}
|
||||
{--name= : The name of the new admin user}
|
||||
{--password= : The password to assign to the new admin user}
|
||||
{--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}';
|
||||
{--password= : The password to assign to the new admin user}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -45,35 +42,28 @@ class CreateAdmin extends Command
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
* @throws \BookStack\Exceptions\NotFoundException
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$details = $this->snakeCaseOptions();
|
||||
$details = $this->options();
|
||||
|
||||
if (empty($details['email'])) {
|
||||
$details['email'] = $this->ask('Please specify an email address for the new admin user');
|
||||
}
|
||||
|
||||
if (empty($details['name'])) {
|
||||
$details['name'] = $this->ask('Please specify a name for the new admin user');
|
||||
}
|
||||
|
||||
if (empty($details['password'])) {
|
||||
if (empty($details['external_auth_id'])) {
|
||||
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
|
||||
} else {
|
||||
$details['password'] = Str::random(32);
|
||||
}
|
||||
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
|
||||
}
|
||||
|
||||
$validator = Validator::make($details, [
|
||||
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
|
||||
'name' => ['required', 'min:2'],
|
||||
'password' => ['required_without:external_auth_id', Password::default()],
|
||||
'external_auth_id' => ['required_without:password'],
|
||||
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
|
||||
'name' => ['required', 'min:2'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -94,14 +84,4 @@ class CreateAdmin extends Command
|
||||
|
||||
return SymfonyCommand::SUCCESS;
|
||||
}
|
||||
|
||||
protected function snakeCaseOptions(): array
|
||||
{
|
||||
$returnOpts = [];
|
||||
foreach ($this->options() as $key => $value) {
|
||||
$returnOpts[str_replace('-', '_', $key)] = $value;
|
||||
}
|
||||
|
||||
return $returnOpts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class Deletion extends Model implements Loggable
|
||||
/**
|
||||
* Get a URL for this specific deletion.
|
||||
*/
|
||||
public function getUrl(string $path = 'restore'): string
|
||||
public function getUrl($path): string
|
||||
{
|
||||
return url("/settings/recycle-bin/{$this->id}/" . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property string $slug
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property Carbon $deleted_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property bool $restricted
|
||||
|
||||
@@ -46,10 +46,19 @@ class PageRevision extends Model
|
||||
|
||||
/**
|
||||
* Get the url for this revision.
|
||||
*
|
||||
* @param null|string $path
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUrl(string $path = ''): string
|
||||
public function getUrl($path = null)
|
||||
{
|
||||
return $this->page->getUrl('/revisions/' . $this->id . '/' . ltrim($path, '/'));
|
||||
$url = $this->page->getUrl() . '/revisions/' . $this->id;
|
||||
if ($path) {
|
||||
return $url . '/' . trim($path, '/');
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -92,7 +92,6 @@ class ExportFormatter
|
||||
$html = view('pages.export', [
|
||||
'page' => $page,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
@@ -114,7 +113,6 @@ class ExportFormatter
|
||||
'chapter' => $chapter,
|
||||
'pages' => $pages,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
@@ -132,7 +130,6 @@ class ExportFormatter
|
||||
'book' => $book,
|
||||
'bookChildren' => $bookTree,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,15 +7,14 @@ use Barryvdh\Snappy\Facades\SnappyPdf;
|
||||
|
||||
class PdfGenerator
|
||||
{
|
||||
const ENGINE_DOMPDF = 'dompdf';
|
||||
const ENGINE_WKHTML = 'wkhtml';
|
||||
|
||||
/**
|
||||
* Generate PDF content from the given HTML content.
|
||||
*/
|
||||
public function fromHtml(string $html): string
|
||||
{
|
||||
if ($this->getActiveEngine() === self::ENGINE_WKHTML) {
|
||||
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
|
||||
|
||||
if ($useWKHTML) {
|
||||
$pdf = SnappyPDF::loadHTML($html);
|
||||
$pdf->setOption('print-media-type', true);
|
||||
} else {
|
||||
@@ -24,15 +23,4 @@ class PdfGenerator
|
||||
|
||||
return $pdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active PDF engine.
|
||||
* Returns the value of an `ENGINE_` const on this class.
|
||||
*/
|
||||
public function getActiveEngine(): string
|
||||
{
|
||||
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
|
||||
|
||||
return $useWKHTML ? self::ENGINE_WKHTML : self::ENGINE_DOMPDF;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,9 @@ class TrashCan
|
||||
{
|
||||
/**
|
||||
* Send a shelf to the recycle bin.
|
||||
*
|
||||
* @throws NotifyException
|
||||
*/
|
||||
public function softDestroyShelf(Bookshelf $shelf)
|
||||
{
|
||||
$this->ensureDeletable($shelf);
|
||||
Deletion::createForEntity($shelf);
|
||||
$shelf->delete();
|
||||
}
|
||||
@@ -39,7 +36,6 @@ class TrashCan
|
||||
*/
|
||||
public function softDestroyBook(Book $book)
|
||||
{
|
||||
$this->ensureDeletable($book);
|
||||
Deletion::createForEntity($book);
|
||||
|
||||
foreach ($book->pages as $page) {
|
||||
@@ -61,7 +57,6 @@ class TrashCan
|
||||
public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
|
||||
{
|
||||
if ($recordDelete) {
|
||||
$this->ensureDeletable($chapter);
|
||||
Deletion::createForEntity($chapter);
|
||||
}
|
||||
|
||||
@@ -82,47 +77,19 @@ class TrashCan
|
||||
public function softDestroyPage(Page $page, bool $recordDelete = true)
|
||||
{
|
||||
if ($recordDelete) {
|
||||
$this->ensureDeletable($page);
|
||||
Deletion::createForEntity($page);
|
||||
}
|
||||
|
||||
$page->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the given entity is deletable.
|
||||
* Is not for permissions, but logical conditions within the application.
|
||||
* Will throw if not deletable.
|
||||
*
|
||||
* @throws NotifyException
|
||||
*/
|
||||
protected function ensureDeletable(Entity $entity): void
|
||||
{
|
||||
$customHomeId = intval(explode(':', setting('app-homepage', '0:'))[0]);
|
||||
$customHomeActive = setting('app-homepage-type') === 'page';
|
||||
$removeCustomHome = false;
|
||||
|
||||
// Check custom homepage usage for pages
|
||||
if ($entity instanceof Page && $entity->id === $customHomeId) {
|
||||
if ($customHomeActive) {
|
||||
throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
|
||||
// Check if set as custom homepage & remove setting if not used or throw error if active
|
||||
$customHome = setting('app-homepage', '0:');
|
||||
if (intval($page->id) === intval(explode(':', $customHome)[0])) {
|
||||
if (setting('app-homepage-type') === 'page') {
|
||||
throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
|
||||
}
|
||||
$removeCustomHome = true;
|
||||
}
|
||||
|
||||
// Check custom homepage usage within chapters or books
|
||||
if ($entity instanceof Chapter || $entity instanceof Book) {
|
||||
if ($entity->pages()->where('id', '=', $customHomeId)->exists()) {
|
||||
if ($customHomeActive) {
|
||||
throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
|
||||
}
|
||||
$removeCustomHome = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($removeCustomHome) {
|
||||
setting()->remove('app-homepage');
|
||||
}
|
||||
|
||||
$page->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,6 @@ use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
@@ -365,22 +364,15 @@ class PageController extends Controller
|
||||
*/
|
||||
public function showRecentlyUpdated()
|
||||
{
|
||||
$visibleBelongsScope = function (BelongsTo $query) {
|
||||
$query->scopes('visible');
|
||||
};
|
||||
|
||||
$pages = Page::visible()->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
|
||||
->orderBy('updated_at', 'desc')
|
||||
$pages = Page::visible()->orderBy('updated_at', 'desc')
|
||||
->paginate(20)
|
||||
->setPath(url('/pages/recently-updated'));
|
||||
|
||||
$this->setPageTitle(trans('entities.recently_updated_pages'));
|
||||
|
||||
return view('common.detailed-listing-paginated', [
|
||||
'title' => trans('entities.recently_updated_pages'),
|
||||
'entities' => $pages,
|
||||
'showUpdatedBy' => true,
|
||||
'showPath' => true,
|
||||
'title' => trans('entities.recently_updated_pages'),
|
||||
'entities' => $pages,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -62,7 +61,6 @@ class UserController extends Controller
|
||||
$this->checkPermission('users-manage');
|
||||
$authMethod = config('auth.method');
|
||||
$roles = $this->userRepo->getAllRoles();
|
||||
$this->setPageTitle(trans('settings.users_add_new'));
|
||||
|
||||
return view('users.create', ['authMethod' => $authMethod, 'roles' => $roles]);
|
||||
}
|
||||
@@ -77,9 +75,8 @@ class UserController extends Controller
|
||||
{
|
||||
$this->checkPermission('users-manage');
|
||||
$validationRules = [
|
||||
'name' => ['required'],
|
||||
'email' => ['required', 'email', 'unique:users,email'],
|
||||
'setting' => ['array'],
|
||||
'name' => ['required'],
|
||||
'email' => ['required', 'email', 'unique:users,email'],
|
||||
];
|
||||
|
||||
$authMethod = config('auth.method');
|
||||
@@ -102,30 +99,20 @@ class UserController extends Controller
|
||||
}
|
||||
|
||||
$user->refreshSlug();
|
||||
$user->save();
|
||||
|
||||
DB::transaction(function () use ($user, $sendInvite, $request) {
|
||||
$user->save();
|
||||
if ($sendInvite) {
|
||||
$this->inviteService->sendInvitation($user);
|
||||
}
|
||||
|
||||
// Save user-specific settings
|
||||
if ($request->filled('setting')) {
|
||||
foreach ($request->get('setting') as $key => $value) {
|
||||
setting()->putUser($user, $key, $value);
|
||||
}
|
||||
}
|
||||
if ($request->filled('roles')) {
|
||||
$roles = $request->get('roles');
|
||||
$this->userRepo->setUserRoles($user, $roles);
|
||||
}
|
||||
|
||||
if ($sendInvite) {
|
||||
$this->inviteService->sendInvitation($user);
|
||||
}
|
||||
$this->userRepo->downloadAndAssignUserAvatar($user);
|
||||
|
||||
if ($request->filled('roles')) {
|
||||
$roles = $request->get('roles');
|
||||
$this->userRepo->setUserRoles($user, $roles);
|
||||
}
|
||||
|
||||
$this->userRepo->downloadAndAssignUserAvatar($user);
|
||||
|
||||
$this->logActivity(ActivityType::USER_CREATE, $user);
|
||||
});
|
||||
$this->logActivity(ActivityType::USER_CREATE, $user);
|
||||
|
||||
return redirect('/settings/users');
|
||||
}
|
||||
@@ -207,7 +194,7 @@ class UserController extends Controller
|
||||
$user->external_auth_id = $request->get('external_auth_id');
|
||||
}
|
||||
|
||||
// Save user-specific settings
|
||||
// Save an user-specific settings
|
||||
if ($request->filled('setting')) {
|
||||
foreach ($request->get('setting') as $key => $value) {
|
||||
setting()->putUser($user, $key, $value);
|
||||
|
||||
@@ -2,33 +2,35 @@
|
||||
|
||||
namespace BookStack\Notifications;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class UserInvite extends MailNotification
|
||||
{
|
||||
public $token;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*
|
||||
* @param string $token
|
||||
*/
|
||||
public function __construct(string $token)
|
||||
public function __construct($token)
|
||||
{
|
||||
$this->token = $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
$appName = ['appName' => setting('app-name')];
|
||||
$language = setting()->getUser($notifiable, 'language');
|
||||
|
||||
return $this->newMailMessage()
|
||||
->subject(trans('auth.user_invite_email_subject', $appName, $language))
|
||||
->greeting(trans('auth.user_invite_email_greeting', $appName, $language))
|
||||
->line(trans('auth.user_invite_email_text', [], $language))
|
||||
->action(trans('auth.user_invite_email_action', [], $language), url('/register/invite/' . $this->token));
|
||||
->subject(trans('auth.user_invite_email_subject', $appName))
|
||||
->greeting(trans('auth.user_invite_email_greeting', $appName))
|
||||
->line(trans('auth.user_invite_email_text'))
|
||||
->action(trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Auth\Access\LdapService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\HttpFetchException;
|
||||
use Exception;
|
||||
@@ -16,6 +17,7 @@ class UserAvatars
|
||||
{
|
||||
$this->imageService = $imageService;
|
||||
$this->http = $http;
|
||||
$ldapService = app()->make(LdapService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"socialiteproviders/okta": "^4.1",
|
||||
"socialiteproviders/slack": "^4.1",
|
||||
"socialiteproviders/twitch": "^5.3",
|
||||
"ssddanbrown/htmldiff": "^1.0.2"
|
||||
"ssddanbrown/htmldiff": "^1.0.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.16",
|
||||
|
||||
849
composer.lock
generated
849
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,6 @@ RUN apt-get update -y \
|
||||
&& apt-get install -y git zip unzip libpng-dev libldap2-dev libzip-dev wait-for-it \
|
||||
&& docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \
|
||||
&& docker-php-ext-install pdo_mysql gd ldap zip \
|
||||
&& pecl install xdebug \
|
||||
&& docker-php-ext-enable xdebug \
|
||||
&& a2enmod rewrite \
|
||||
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
|
||||
&& sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
zend_extension=xdebug
|
||||
|
||||
[xdebug]
|
||||
xdebug.mode=debug
|
||||
xdebug.client_host=host.docker.internal
|
||||
xdebug.start_with_request=yes
|
||||
xdebug.client_port=9090
|
||||
@@ -38,10 +38,7 @@ services:
|
||||
- ${DEV_PORT:-8080}:80
|
||||
volumes:
|
||||
- ./:/app
|
||||
- ./dev/docker/php/conf.d/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
entrypoint: /app/dev/docker/entrypoint.app.sh
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
node:
|
||||
image: node:alpine
|
||||
working_dir: /app
|
||||
|
||||
817
package-lock.json
generated
817
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -7,6 +7,8 @@
|
||||
"build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main",
|
||||
"build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
|
||||
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify",
|
||||
"build:js_editor:dev": "esbuild --bundle ./resources/js/editor.js --outfile=public/dist/editor.js --sourcemap --target=es2019 --main-fields=module,main",
|
||||
"build:js_editor:watch": "chokidar --initial \"./resources/js/editor.js\" \"./resources/js/editor/**/*.js\" -c \"npm run build:js_editor:dev\"",
|
||||
"build": "npm-run-all --parallel build:*:dev",
|
||||
"production": "npm-run-all --parallel build:*:production",
|
||||
"dev": "npm-run-all --parallel watch livereload",
|
||||
@@ -16,18 +18,27 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "^3.0",
|
||||
"esbuild": "0.14.13",
|
||||
"esbuild": "0.13.12",
|
||||
"livereload": "^0.9.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"punycode": "^2.1.1",
|
||||
"sass": "^1.49.0"
|
||||
"sass": "^1.43.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"clipboard": "^2.0.8",
|
||||
"codemirror": "^5.65.1",
|
||||
"codemirror": "^5.63.3",
|
||||
"crelt": "^1.0.5",
|
||||
"dropzone": "^5.9.3",
|
||||
"markdown-it": "^12.3.2",
|
||||
"markdown-it": "^12.2.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"prosemirror-commands": "^1.1.12",
|
||||
"prosemirror-example-setup": "^1.1.2",
|
||||
"prosemirror-markdown": "^1.6.0",
|
||||
"prosemirror-model": "^1.15.0",
|
||||
"prosemirror-schema-list": "^1.1.6",
|
||||
"prosemirror-state": "^1.3.4",
|
||||
"prosemirror-tables": "^1.1.1",
|
||||
"prosemirror-view": "^1.23.2",
|
||||
"sortablejs": "^1.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
72
public/dist/app.js
vendored
72
public/dist/app.js
vendored
File diff suppressed because one or more lines are too long
1
public/dist/export-styles.css
vendored
1
public/dist/export-styles.css
vendored
File diff suppressed because one or more lines are too long
1
public/dist/print-styles.css
vendored
1
public/dist/print-styles.css
vendored
@@ -1 +0,0 @@
|
||||
:root{--color-primary: #206ea7;--color-primary-light: rgba(32,110,167,0.15);--color-page: #206ea7;--color-page-draft: #7e50b1;--color-chapter: #af4d0d;--color-book: #077b70;--color-bookshelf: #a94747}header{display:none}html,body{font-size:12px;background-color:#fff}.page-content{margin:0 auto}.print-hidden{display:none !important}.tri-layout-container{grid-template-columns:1fr;grid-template-areas:"b";margin-inline-start:0;margin-inline-end:0;display:block}.card{box-shadow:none}.content-wrap.card{padding-inline-start:0;padding-inline-end:0}/*# sourceMappingURL=print-styles.css.map */
|
||||
1
public/dist/styles.css
vendored
1
public/dist/styles.css
vendored
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB |
24
readme.md
24
readme.md
@@ -1,7 +1,7 @@
|
||||
# BookStack
|
||||
|
||||
[](https://github.com/BookStackApp/BookStack/releases/latest)
|
||||
[](https://github.com/BookStackApp/BookStack/blob/development/LICENSE)
|
||||
[](https://github.com/BookStackApp/BookStack/blob/master/LICENSE)
|
||||
[](https://crowdin.com/project/bookstack)
|
||||
[](https://discord.gg/ztkBqR2)
|
||||
[](https://gh-stats.bookstackapp.com/)
|
||||
@@ -34,14 +34,11 @@ Big thanks to these companies for supporting the project.
|
||||
Note: Listed services are not tested, vetted nor supported by the official BookStack project in any manner.
|
||||
[View all sponsors](https://github.com/sponsors/ssddanbrown).
|
||||
|
||||
#### Silver Sponsors
|
||||
#### Silver Sponsor
|
||||
|
||||
<table><tbody><tr>
|
||||
<td><a href="https://www.diagrams.net/" target="_blank">
|
||||
<img width="400" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
|
||||
</a></td>
|
||||
<td><a href="https://cloudabove.com/hosting" target="_blank">
|
||||
<img height="100" src="https://raw.githubusercontent.com/BookStackApp/website/main/static/images/sponsors/cloudabove.svg" alt="Cloudabove">
|
||||
<img width="420" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/diagramsnet.png" alt="Diagrams.net">
|
||||
</a></td>
|
||||
</tr></tbody></table>
|
||||
|
||||
@@ -82,7 +79,7 @@ Feature releases, and some patch releases, will be accompanied by a post on the
|
||||
|
||||
## 🛠️ Development & Testing
|
||||
|
||||
All development on BookStack is currently done on the `development` branch. When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
|
||||
All development on BookStack is currently done on the master branch. When it's time for a release the master branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
|
||||
|
||||
* [Node.js](https://nodejs.org/en/) v14.0+
|
||||
|
||||
@@ -159,11 +156,6 @@ Once the database has been migrated & seeded, you can run the tests like so:
|
||||
docker-compose run app php vendor/bin/phpunit
|
||||
```
|
||||
|
||||
#### Debugging
|
||||
|
||||
The docker-compose setup ships with Xdebug, which you can listen to on port 9090.
|
||||
NB : For some editors like Visual Studio Code, you might need to map your workspace folder to the /app folder within the docker container for this to work.
|
||||
|
||||
## 🌎 Translations
|
||||
|
||||
Translations for text within BookStack is managed through the [BookStack project on Crowdin](https://crowdin.com/project/bookstack). Some strings have colon-prefixed variables in such as `:userName`. Leave these values as they are as they will be replaced at run-time. Crowdin is the preferred way to provide translations, otherwise the raw translations files can be found within the `resources/lang` path.
|
||||
@@ -178,9 +170,9 @@ Feel free to create issues to request new features or to report bugs & problems.
|
||||
|
||||
Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project.
|
||||
|
||||
Pull requests should be created from the `development` branch since they will be merged back into `development` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
|
||||
Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
|
||||
|
||||
The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/development/.github/CODE_OF_CONDUCT.md).
|
||||
The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/CODE_OF_CONDUCT.md).
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
@@ -188,7 +180,7 @@ Security information for administering a BookStack instance can be found on the
|
||||
|
||||
If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates).
|
||||
|
||||
If you would like to report a security concern, details of doing so can [can be found here](https://github.com/BookStackApp/BookStack/blob/development/.github/SECURITY.md).
|
||||
If you would like to report a security concern, details of doing so can [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/SECURITY.md).
|
||||
|
||||
## ♿ Accessibility
|
||||
|
||||
@@ -206,7 +198,7 @@ The BookStack source is provided under the MIT License. The libraries used by, a
|
||||
|
||||
The great people that have worked to build and improve BookStack can [be seen here](https://github.com/BookStackApp/BookStack/graphs/contributors).
|
||||
|
||||
The wonderful people that have provided translations, either through GitHub or via Crowdin [can be seen here](https://github.com/BookStackApp/BookStack/blob/development/.github/translators.txt).
|
||||
The wonderful people that have provided translations, either through GitHub or via Crowdin [can be seen here](https://github.com/BookStackApp/BookStack/blob/master/.github/translators.txt).
|
||||
|
||||
These are the great open-source projects used to help build BookStack:
|
||||
|
||||
|
||||
@@ -41,9 +41,7 @@ class EditorToolbox {
|
||||
if (cName === tabName) this.contentElements[i].style.display = 'block';
|
||||
}
|
||||
|
||||
if (openToolbox && !this.elem.classList.contains('open')) {
|
||||
this.toggle();
|
||||
}
|
||||
if (openToolbox) this.elem.classList.add('open');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -74,10 +74,6 @@ class ImageManager {
|
||||
|
||||
this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
|
||||
|
||||
this.listContainer.addEventListener('error', event => {
|
||||
event.target.src = baseUrl('loading_error.png');
|
||||
}, true);
|
||||
|
||||
onSelect(this.selectButton, () => {
|
||||
if (this.callback) {
|
||||
this.callback(this.lastSelected);
|
||||
@@ -122,9 +118,6 @@ class ImageManager {
|
||||
};
|
||||
|
||||
const {data: html} = await window.$http.get(`images/${this.type}`, params);
|
||||
if (params.page === 1) {
|
||||
this.listContainer.innerHTML = '';
|
||||
}
|
||||
this.addReturnedHtmlElementsToList(html);
|
||||
removeLoading(this.listContainer);
|
||||
}
|
||||
|
||||
@@ -112,11 +112,6 @@ class MarkdownEditor {
|
||||
if (scrollText) {
|
||||
this.scrollToText(scrollText);
|
||||
}
|
||||
|
||||
// Refresh CodeMirror on container resize
|
||||
const resizeDebounced = debounce(() => code.updateLayout(this.cm), 100, false);
|
||||
const observer = new ResizeObserver(resizeDebounced);
|
||||
observer.observe(this.elem);
|
||||
}
|
||||
|
||||
// Update the input content and render the display.
|
||||
@@ -400,9 +395,8 @@ class MarkdownEditor {
|
||||
actionInsertImage() {
|
||||
const cursorPos = this.cm.getCursor('from');
|
||||
window.ImageManager.show(image => {
|
||||
const imageUrl = image.thumbs.display || image.url;
|
||||
let selectedText = this.cm.getSelection();
|
||||
let newText = "[](" + image.url + ")";
|
||||
let newText = "[](" + image.url + ")";
|
||||
this.cm.focus();
|
||||
this.cm.replaceSelection(newText);
|
||||
this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
|
||||
|
||||
@@ -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
18
resources/js/editor.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import MarkdownView from "./editor/MarkdownView";
|
||||
import ProseMirrorView from "./editor/ProseMirrorView";
|
||||
|
||||
// Next step: https://prosemirror.net/examples/menu/
|
||||
|
||||
const place = document.querySelector("#editor");
|
||||
let view = new ProseMirrorView(place, document.getElementById('content').innerHTML);
|
||||
|
||||
const markdownToggle = document.getElementById('markdown-toggle');
|
||||
markdownToggle.addEventListener('change', event => {
|
||||
const View = markdownToggle.checked ? MarkdownView : ProseMirrorView;
|
||||
if (view instanceof View) return
|
||||
const content = view.content
|
||||
console.log(content);
|
||||
view.destroy()
|
||||
view = new View(place, content)
|
||||
view.focus()
|
||||
});
|
||||
28
resources/js/editor/MarkdownView.js
Normal file
28
resources/js/editor/MarkdownView.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import {htmlToDoc, docToHtml} from "./util";
|
||||
|
||||
import parser from "./markdown-parser";
|
||||
import serializer from "./markdown-serializer";
|
||||
|
||||
class MarkdownView {
|
||||
constructor(target, content) {
|
||||
// Build DOM from content
|
||||
const htmlDoc = htmlToDoc(content);
|
||||
const markdown = serializer.serialize(htmlDoc);
|
||||
|
||||
this.textarea = target.appendChild(document.createElement("textarea"))
|
||||
this.textarea.value = markdown;
|
||||
this.textarea.style.width = '1000px';
|
||||
this.textarea.style.height = '1000px';
|
||||
}
|
||||
|
||||
get content() {
|
||||
const markdown = this.textarea.value;
|
||||
const doc = parser.parse(markdown);
|
||||
return docToHtml(doc);
|
||||
}
|
||||
|
||||
focus() { this.textarea.focus() }
|
||||
destroy() { this.textarea.remove() }
|
||||
}
|
||||
|
||||
export default MarkdownView;
|
||||
52
resources/js/editor/ProseMirrorView.js
Normal file
52
resources/js/editor/ProseMirrorView.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import {EditorState} from "prosemirror-state";
|
||||
import {EditorView} from "prosemirror-view";
|
||||
import {exampleSetup} from "prosemirror-example-setup";
|
||||
import {tableEditing} from "prosemirror-tables";
|
||||
|
||||
import {DOMParser} from "prosemirror-model";
|
||||
|
||||
import schema from "./schema";
|
||||
import menu from "./menu";
|
||||
import nodeViews from "./node-views";
|
||||
import {stateToHtml} from "./util";
|
||||
import {columnResizing} from "./plugins/table-resizing";
|
||||
|
||||
class ProseMirrorView {
|
||||
constructor(target, content) {
|
||||
|
||||
// Build DOM from content
|
||||
const renderDoc = document.implementation.createHTMLDocument();
|
||||
renderDoc.body.innerHTML = content;
|
||||
|
||||
this.view = new EditorView(target, {
|
||||
state: EditorState.create({
|
||||
doc: DOMParser.fromSchema(schema).parse(renderDoc.body),
|
||||
plugins: [
|
||||
...exampleSetup({schema, menuBar: false}),
|
||||
menu,
|
||||
columnResizing(),
|
||||
tableEditing(),
|
||||
]
|
||||
}),
|
||||
nodeViews,
|
||||
});
|
||||
|
||||
// Fix for native handles (Such as table size handling) in some browsers
|
||||
document.execCommand("enableObjectResizing", false, "false")
|
||||
document.execCommand("enableInlineTableEditing", false, "false")
|
||||
}
|
||||
|
||||
get content() {
|
||||
return stateToHtml(this.view.state);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.view.focus()
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.view.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export default ProseMirrorView;
|
||||
102
resources/js/editor/commands.js
Normal file
102
resources/js/editor/commands.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @param {String} attrName
|
||||
* @param {String} attrValue
|
||||
* @return {PmCommandHandler}
|
||||
*/
|
||||
export function setBlockAttr(attrName, attrValue) {
|
||||
return function (state, dispatch) {
|
||||
const ref = state.selection;
|
||||
const from = ref.from;
|
||||
const to = ref.to;
|
||||
let applicable = false;
|
||||
|
||||
state.doc.nodesBetween(from, to, function (node, pos) {
|
||||
if (applicable) {
|
||||
return false
|
||||
}
|
||||
if (!node.isTextblock || node.attrs[attrName] === attrValue) {
|
||||
return
|
||||
}
|
||||
|
||||
applicable = node.attrs[attrName] !== undefined;
|
||||
});
|
||||
|
||||
if (!applicable) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
tr.doc.nodesBetween(from, to, function (node, pos) {
|
||||
const nodeAttrs = Object.assign({}, node.attrs);
|
||||
if (node.attrs[attrName] !== undefined) {
|
||||
nodeAttrs[attrName] = attrValue;
|
||||
tr.setBlockType(pos, pos + 1, node.type, nodeAttrs)
|
||||
}
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmNodeType} blockType
|
||||
* @return {PmCommandHandler}
|
||||
*/
|
||||
export function insertBlockBefore(blockType) {
|
||||
return function (state, dispatch) {
|
||||
const startPosition = state.selection.$from.before(1);
|
||||
|
||||
if (dispatch) {
|
||||
dispatch(state.tr.insert(startPosition, blockType.create()));
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} rows
|
||||
* @param {Number} columns
|
||||
* @param {Object} tableAttrs
|
||||
* @return {PmCommandHandler}
|
||||
*/
|
||||
export function insertTable(rows, columns, tableAttrs) {
|
||||
return function (state, dispatch) {
|
||||
if (!dispatch) return true;
|
||||
|
||||
const tr = state.tr;
|
||||
const nodes = state.schema.nodes;
|
||||
|
||||
const rowNodes = [];
|
||||
for (let y = 0; y < rows; y++) {
|
||||
const rowCells = [];
|
||||
for (let x = 0; x < columns; x++) {
|
||||
const cellText = nodes.paragraph.create(null);
|
||||
rowCells.push(nodes.table_cell.create(null, cellText));
|
||||
}
|
||||
rowNodes.push(nodes.table_row.create(null, rowCells));
|
||||
}
|
||||
|
||||
const table = nodes.table.create(tableAttrs, rowNodes);
|
||||
tr.replaceSelectionWith(table);
|
||||
dispatch(tr);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {PmCommandHandler}
|
||||
*/
|
||||
export function removeMarks() {
|
||||
return function (state, dispatch) {
|
||||
if (dispatch) {
|
||||
dispatch(state.tr.removeMark(state.selection.from, state.selection.to, null));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
69
resources/js/editor/markdown-parser.js
Normal file
69
resources/js/editor/markdown-parser.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import schema from "./schema";
|
||||
import markdownit from "markdown-it";
|
||||
import {MarkdownParser, defaultMarkdownParser} from "prosemirror-markdown";
|
||||
import {htmlToDoc, KeyedMultiStack} from "./util";
|
||||
|
||||
const tokens = defaultMarkdownParser.tokens;
|
||||
|
||||
// These are really a placeholder on the object to allow the below
|
||||
// parser.tokenHandlers.html_[block/inline] hacks to work as desired.
|
||||
tokens.html_block = {block: "callout", noCloseToken: true};
|
||||
tokens.html_inline = {mark: "underline"};
|
||||
|
||||
const tokenizer = markdownit("commonmark", {html: true});
|
||||
const parser = new MarkdownParser(schema, tokenizer, tokens);
|
||||
|
||||
// When we come across HTML blocks we use the document schema to parse them
|
||||
// into nodes then re-add those back into the parser state.
|
||||
parser.tokenHandlers.html_block = function(state, tok, tokens, i) {
|
||||
const contentDoc = htmlToDoc(tok.content || '');
|
||||
for (const node of contentDoc.content.content) {
|
||||
state.addNode(node.type, node.attrs, node.content);
|
||||
}
|
||||
};
|
||||
|
||||
// When we come across inline HTML we parse out the tag and keep track of
|
||||
// that in a stack, along with the marks they parse out to.
|
||||
// We open/close the marks within the state depending on the tag open/close type.
|
||||
const tagStack = new KeyedMultiStack();
|
||||
parser.tokenHandlers.html_inline = function(state, tok, tokens, i) {
|
||||
const isClosing = tok.content.startsWith('</');
|
||||
const isSelfClosing = tok.content.endsWith('/>');
|
||||
const tagName = parseTagNameFromHtmlTokenContent(tok.content);
|
||||
|
||||
if (!isClosing) {
|
||||
const completeTag = isSelfClosing ? tok.content : `${tok.content}a</${tagName}>`;
|
||||
const marks = extractMarksFromHtml(completeTag);
|
||||
tagStack.push(tagName, marks);
|
||||
for (const mark of marks) {
|
||||
state.openMark(mark);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelfClosing || isClosing) {
|
||||
const marks = (tagStack.pop(tagName) || []).reverse();
|
||||
for (const mark of marks) {
|
||||
state.closeMark(mark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} html
|
||||
* @return {PmMark[]}
|
||||
*/
|
||||
function extractMarksFromHtml(html) {
|
||||
const contentDoc = htmlToDoc('<p>' + (html || '') + '</p>');
|
||||
const marks = contentDoc?.content?.content?.[0]?.content?.content?.[0]?.marks;
|
||||
return marks || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tokenContent
|
||||
* @return {string}
|
||||
*/
|
||||
function parseTagNameFromHtmlTokenContent(tokenContent) {
|
||||
return tokenContent.split(' ')[0].replace(/[<>\/]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
export default parser;
|
||||
138
resources/js/editor/markdown-serializer.js
Normal file
138
resources/js/editor/markdown-serializer.js
Normal file
@@ -0,0 +1,138 @@
|
||||
import {MarkdownSerializer, defaultMarkdownSerializer, MarkdownSerializerState} from "prosemirror-markdown";
|
||||
import {docToHtml} from "./util";
|
||||
|
||||
const nodes = defaultMarkdownSerializer.nodes;
|
||||
const marks = defaultMarkdownSerializer.marks;
|
||||
|
||||
|
||||
nodes.callout = function (state, node) {
|
||||
writeNodeAsHtml(state, node);
|
||||
};
|
||||
|
||||
nodes.table = function (state, node) {
|
||||
writeNodeAsHtml(state, node);
|
||||
};
|
||||
|
||||
nodes.iframe = function (state, node) {
|
||||
writeNodeAsHtml(state, node);
|
||||
};
|
||||
|
||||
nodes.details = function (state, node) {
|
||||
wrapNodeWithHtml(state, node, '<details>', '</details>');
|
||||
};
|
||||
|
||||
nodes.details_summary = function(state, node) {
|
||||
writeNodeAsHtml(state, node);
|
||||
};
|
||||
|
||||
function isPlainURL(link, parent, index, side) {
|
||||
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) {
|
||||
return false
|
||||
}
|
||||
const content = parent.child(index + (side < 0 ? -1 : 0));
|
||||
if (!content.isText || content.text != link.attrs.href || content.marks[content.marks.length - 1] != link) {
|
||||
return false
|
||||
}
|
||||
if (index == (side < 0 ? 1 : parent.childCount - 1)) {
|
||||
return true
|
||||
}
|
||||
const next = parent.child(index + (side < 0 ? -2 : 1));
|
||||
return !link.isInSet(next.marks)
|
||||
}
|
||||
|
||||
marks.link = {
|
||||
open(state, mark, parent, index) {
|
||||
const attrs = mark.attrs;
|
||||
if (attrs.target) {
|
||||
return `<a href="${attrs.target}" ${attrs.title ? `title="${attrs.title}"` : ''} target="${attrs.target}">`
|
||||
}
|
||||
return isPlainURL(mark, parent, index, 1) ? "<" : "["
|
||||
},
|
||||
close(state, mark, parent, index) {
|
||||
if (mark.attrs.target) {
|
||||
return `</a>`;
|
||||
}
|
||||
return isPlainURL(mark, parent, index, -1) ? ">"
|
||||
: "](" + state.esc(mark.attrs.href) + (mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") + ")"
|
||||
}
|
||||
};
|
||||
|
||||
marks.underline = {
|
||||
open: '<span style="text-decoration: underline;">',
|
||||
close: '</span>',
|
||||
};
|
||||
|
||||
marks.strike = {
|
||||
open: '<span style="text-decoration: line-through;">',
|
||||
close: '</span>',
|
||||
};
|
||||
|
||||
marks.superscript = {
|
||||
open: '<sup>',
|
||||
close: '</sup>',
|
||||
};
|
||||
|
||||
marks.subscript = {
|
||||
open: '<sub>',
|
||||
close: '</sub>',
|
||||
};
|
||||
|
||||
marks.text_color = {
|
||||
open(state, mark, parent, index) {
|
||||
return `<span style="color: ${mark.attrs.color};">`
|
||||
},
|
||||
close: '</span>',
|
||||
};
|
||||
|
||||
marks.background_color = {
|
||||
open(state, mark, parent, index) {
|
||||
return `<span style="background-color: ${mark.attrs.color};">`
|
||||
},
|
||||
close: '</span>',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {MarkdownSerializerState} state
|
||||
* @param {PmNode} node
|
||||
*/
|
||||
function writeNodeAsHtml(state, node) {
|
||||
const html = docToHtml({content: [node]});
|
||||
state.write(html);
|
||||
state.ensureNewLine();
|
||||
state.write('\n');
|
||||
state.closeBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MarkdownSerializerState} state
|
||||
* @param {PmNode} node
|
||||
* @param {String} openTag
|
||||
* @param {String} closeTag
|
||||
*/
|
||||
function wrapNodeWithHtml(state, node, openTag, closeTag) {
|
||||
state.write(openTag);
|
||||
state.ensureNewLine();
|
||||
state.renderContent(node);
|
||||
state.write(closeTag);
|
||||
state.closeBlock();
|
||||
state.ensureNewLine();
|
||||
state.write('\n');
|
||||
}
|
||||
|
||||
// Update serializers to just write out as HTML if we have an attribute
|
||||
// or element that cannot be represented in commonmark without losing
|
||||
// formatting or content.
|
||||
for (const [nodeType, serializerFunction] of Object.entries(nodes)) {
|
||||
nodes[nodeType] = function (state, node, parent, index) {
|
||||
if (node.attrs.align || node.attrs.height || node.attrs.width) {
|
||||
writeNodeAsHtml(state, node);
|
||||
} else {
|
||||
serializerFunction(state, node, parent, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const serializer = new MarkdownSerializer(nodes, marks);
|
||||
|
||||
export default serializer;
|
||||
62
resources/js/editor/menu/ColorPickerGrid.js
Normal file
62
resources/js/editor/menu/ColorPickerGrid.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import crel from "crelt"
|
||||
import {prefix} from "./menu-utils";
|
||||
import {TextSelection} from "prosemirror-state"
|
||||
import {expandSelectionToMark} from "../util";
|
||||
|
||||
|
||||
class ColorPickerGrid {
|
||||
|
||||
constructor(markType, attrName, colors) {
|
||||
this.markType = markType;
|
||||
this.colors = colors
|
||||
this.attrName = attrName;
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
|
||||
const colorElems = [];
|
||||
for (const color of this.colors) {
|
||||
const elem = crel("div", {class: prefix + "-color-grid-item", style: `background-color: ${color};`});
|
||||
colorElems.push(elem);
|
||||
}
|
||||
|
||||
const wrap = crel("div", {class: prefix + "-color-grid-container"}, colorElems);
|
||||
wrap.addEventListener('click', event => {
|
||||
if (event.target.classList.contains(prefix + "-color-grid-item")) {
|
||||
const color = event.target.style.backgroundColor;
|
||||
this.onColorSelect(view, color);
|
||||
}
|
||||
});
|
||||
|
||||
function update(state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {dom: wrap, update}
|
||||
}
|
||||
|
||||
onColorSelect(view, color) {
|
||||
const attrs = {[this.attrName]: color};
|
||||
const selection = view.state.selection;
|
||||
const {from, to} = expandSelectionToMark(view.state, selection, this.markType);
|
||||
const tr = view.state.tr;
|
||||
|
||||
const currentColorMarks = selection.$from.marksAcross(selection.$to) || [];
|
||||
const activeRelevantMark = currentColorMarks.filter(mark => {
|
||||
return mark.type === this.markType;
|
||||
})[0];
|
||||
const colorIsActive = activeRelevantMark && activeRelevantMark.attrs[this.attrName] === color;
|
||||
|
||||
tr.removeMark(from, to, this.markType);
|
||||
if (!colorIsActive) {
|
||||
tr.addMark(from, to, this.markType.create(attrs));
|
||||
}
|
||||
|
||||
tr.setSelection(TextSelection.create(tr.doc, from, to));
|
||||
view.dispatch(tr);
|
||||
}
|
||||
}
|
||||
|
||||
export default ColorPickerGrid;
|
||||
59
resources/js/editor/menu/DialogBox.js
Normal file
59
resources/js/editor/menu/DialogBox.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
import {prefix, renderItems} from "./menu-utils";
|
||||
import crel from "crelt";
|
||||
import {getIcon, icons} from "./icons";
|
||||
|
||||
class DialogBox {
|
||||
// :: ([MenuElement], ?Object)
|
||||
// The following options are recognized:
|
||||
//
|
||||
// **`label`**`: string`
|
||||
// : The label to show on the dialog.
|
||||
// **`closer`**`: function`
|
||||
// : The function to run when the dialog should close.
|
||||
constructor(content, options) {
|
||||
this.options = options || {};
|
||||
this.content = Array.isArray(content) ? content : [content];
|
||||
|
||||
this.closeMouseDownListener = null;
|
||||
this.wrap = null;
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
const items = renderItems(this.content, view)
|
||||
|
||||
const titleText = crel("div", {class: prefix + "-dialog-title-text"}, this.options.label);
|
||||
const titleClose = crel("button", {class: prefix + "-dialog-title-close primary-background", type: "button"}, getIcon(icons.close));
|
||||
const titleContent = crel("div", {class: prefix + "-dialog-title"}, titleText, titleClose);
|
||||
const dialog = crel("div", {class: prefix + "-dialog"}, titleContent,
|
||||
crel("div", {class: prefix + "-dialog-content"}, items.dom));
|
||||
const wrap = crel("div", {class: prefix + "-dialog-wrap"}, dialog);
|
||||
this.wrap = wrap;
|
||||
|
||||
this.closeMouseDownListener = (event) => {
|
||||
if (!dialog.contains(event.target) || titleClose.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
wrap.addEventListener("click", this.closeMouseDownListener);
|
||||
|
||||
function update(state) {
|
||||
let inner = items.update(state)
|
||||
wrap.style.display = inner ? "" : "none"
|
||||
return inner;
|
||||
}
|
||||
return {dom: wrap, update}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.options.closer) {
|
||||
this.options.closer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DialogBox;
|
||||
51
resources/js/editor/menu/DialogForm.js
Normal file
51
resources/js/editor/menu/DialogForm.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
import {prefix, renderItems} from "./menu-utils";
|
||||
import crel from "crelt";
|
||||
|
||||
class DialogForm {
|
||||
// :: ([MenuElement], ?Object)
|
||||
// The following options are recognized:
|
||||
//
|
||||
// **`action`**`: function(FormData)`
|
||||
// : The submission action to run when the form is submitted.
|
||||
// **`canceler`**`: function`
|
||||
// : The cancel action to run when the form is cancelled.
|
||||
constructor(content, options) {
|
||||
this.options = options || {};
|
||||
this.content = Array.isArray(content) ? content : [content];
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
const items = renderItems(this.content, view)
|
||||
|
||||
const formButtonCancel = crel("button", {class: prefix + "-dialog-button", type: "button"}, "Cancel");
|
||||
const formButtonSave = crel("button", {class: prefix + "-dialog-button", type: "submit"}, "Save");
|
||||
const footer = crel("div", {class: prefix + "-dialog-footer"}, formButtonCancel, formButtonSave);
|
||||
const form = crel("form", {class: prefix + "-dialog-form", action: '#'}, items.dom, footer);
|
||||
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
if (this.options.action) {
|
||||
this.options.action(new FormData(form));
|
||||
}
|
||||
});
|
||||
|
||||
formButtonCancel.addEventListener('click', event => {
|
||||
if (this.options.canceler) {
|
||||
this.options.canceler();
|
||||
}
|
||||
});
|
||||
|
||||
function update(state) {
|
||||
return items.update(state);
|
||||
}
|
||||
|
||||
return {dom: form, update}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DialogForm;
|
||||
42
resources/js/editor/menu/DialogInput.js
Normal file
42
resources/js/editor/menu/DialogInput.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
import {prefix, randHtmlId} from "./menu-utils";
|
||||
import crel from "crelt";
|
||||
|
||||
class DialogInput {
|
||||
// :: (?Object)
|
||||
// The following options are recognized:
|
||||
//
|
||||
// **`label`**`: string`
|
||||
// : The label to show for the input.
|
||||
// **`id`**`: string`
|
||||
// : The id to use for this input
|
||||
// **`attrs`**`: Object`
|
||||
// : The attributes to add to the input element.
|
||||
// **`value`**`: function(state) -> string`
|
||||
// : The getter for the input value.
|
||||
constructor(options) {
|
||||
this.options = options || {};
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
const id = randHtmlId();
|
||||
const inputAttrs = Object.assign({type: "text", name: this.options.id, id: this.options.id}, this.options.attrs || {})
|
||||
const input = crel("input", inputAttrs);
|
||||
const label = crel("label", {for: id}, this.options.label);
|
||||
|
||||
const rowRap = crel("div", {class: prefix + '-dialog-form-row'}, label, input);
|
||||
|
||||
const update = (state) => {
|
||||
input.value = this.options.value(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {dom: rowRap, update}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DialogInput;
|
||||
53
resources/js/editor/menu/DialogRadioOptions.js
Normal file
53
resources/js/editor/menu/DialogRadioOptions.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
import {prefix, randHtmlId} from "./menu-utils";
|
||||
import crel from "crelt";
|
||||
|
||||
class DialogRadioOptions {
|
||||
/**
|
||||
* Given inputOptions should be keyed by label, with values being values.
|
||||
* Values of empty string will be treated as null.
|
||||
* @param {Object} inputOptions
|
||||
* @param {{label: string, id: string, attrs?: Object, value: function(PmEditorState): string|null}} options
|
||||
*/
|
||||
constructor(inputOptions, options) {
|
||||
this.inputOptions = inputOptions;
|
||||
this.options = options || {};
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
|
||||
const inputs = [];
|
||||
const optionInputLabels = Object.keys(this.inputOptions).map(label => {
|
||||
const inputAttrs = Object.assign({
|
||||
type: "radio",
|
||||
name: this.options.id,
|
||||
value: this.inputOptions[label],
|
||||
class: prefix + '-dialog-radio-option',
|
||||
}, this.options.attrs || {});
|
||||
const input = crel("input", inputAttrs);
|
||||
inputs.push(input);
|
||||
return crel("label", input, label);
|
||||
});
|
||||
|
||||
const optionInputWrap = crel("div", {class: prefix + '-dialog-radio-option-wrap'}, optionInputLabels);
|
||||
|
||||
const label = crel("label", {}, this.options.label);
|
||||
const rowRap = crel("div", {class: prefix + '-dialog-form-row'}, label, optionInputWrap);
|
||||
|
||||
const update = (state) => {
|
||||
const value = this.options.value(state);
|
||||
for (const input of inputs) {
|
||||
input.checked = (input.value === value || (value === null && input.value === ""));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {dom: rowRap, update}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DialogRadioOptions;
|
||||
42
resources/js/editor/menu/DialogTextArea.js
Normal file
42
resources/js/editor/menu/DialogTextArea.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
import {prefix, randHtmlId} from "./menu-utils";
|
||||
import crel from "crelt";
|
||||
|
||||
class DialogTextArea {
|
||||
// :: (?Object)
|
||||
// The following options are recognized:
|
||||
//
|
||||
// **`label`**`: string`
|
||||
// : The label to show for the input.
|
||||
// **`id`**`: string`
|
||||
// : The id to use for this input
|
||||
// **`attrs`**`: Object`
|
||||
// : The attributes to add to the input element.
|
||||
// **`value`**`: function(state) -> string`
|
||||
// : The getter for the input value.
|
||||
constructor(options) {
|
||||
this.options = options || {};
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
const id = randHtmlId();
|
||||
const inputAttrs = Object.assign({type: "text", name: this.options.id, id: this.options.id}, this.options.attrs || {})
|
||||
const input = crel("textarea", inputAttrs);
|
||||
const label = this.options.label ? crel("label", {for: id}, this.options.label) : null;
|
||||
|
||||
const rowRap = crel("div", {class: prefix + '-dialog-textarea-wrap'}, label, input);
|
||||
|
||||
const update = (state) => {
|
||||
input.value = this.options.value(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {dom: rowRap, update}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DialogTextArea;
|
||||
86
resources/js/editor/menu/TableCreatorGrid.js
Normal file
86
resources/js/editor/menu/TableCreatorGrid.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import crel from "crelt"
|
||||
import {prefix} from "./menu-utils";
|
||||
import {insertTable} from "../commands";
|
||||
|
||||
class TableCreatorGrid {
|
||||
|
||||
constructor() {
|
||||
this.size = 10;
|
||||
this.label = null;
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
|
||||
const gridItems = [];
|
||||
for (let y = 0; y < this.size; y++) {
|
||||
for (let x = 0; x < this.size; x++) {
|
||||
const elem = crel("div", {class: prefix + "-table-creator-grid-item"});
|
||||
gridItems.push(elem);
|
||||
elem.addEventListener('mouseenter', event => {
|
||||
this.updateGridItemActiveStatus(elem, gridItems);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const gridWrap = crel("div", {
|
||||
class: prefix + "-table-creator-grid",
|
||||
style: `grid-template-columns: repeat(${this.size}, 14px);`,
|
||||
}, gridItems);
|
||||
|
||||
gridWrap.addEventListener('mouseleave', event => {
|
||||
this.updateGridItemActiveStatus(null, gridItems);
|
||||
});
|
||||
gridWrap.addEventListener('click', event => {
|
||||
if (event.target.classList.contains(prefix + "-table-creator-grid-item")) {
|
||||
const {x, y} = this.getPositionOfGridItem(event.target, gridItems);
|
||||
insertTable(y + 1, x + 1, {
|
||||
style: 'width: 100%;',
|
||||
})(view.state, view.dispatch);
|
||||
}
|
||||
});
|
||||
|
||||
const gridLabel = crel("div", {class: prefix + "-table-creator-grid-label"});
|
||||
this.label = gridLabel;
|
||||
const wrap = crel("div", {class: prefix + "-table-creator-grid-container"}, [gridWrap, gridLabel]);
|
||||
|
||||
function update(state) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {dom: wrap, update}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element|null} newTarget
|
||||
* @param {Element[]} gridItems
|
||||
*/
|
||||
updateGridItemActiveStatus(newTarget, gridItems) {
|
||||
const {x: xPos, y: yPos} = this.getPositionOfGridItem(newTarget, gridItems);
|
||||
|
||||
for (let y = 0; y < this.size; y++) {
|
||||
for (let x = 0; x < this.size; x++) {
|
||||
const active = x <= xPos && y <= yPos;
|
||||
const index = (y * this.size) + x;
|
||||
gridItems[index].classList.toggle(prefix + "-table-creator-grid-item-active", active);
|
||||
}
|
||||
}
|
||||
|
||||
this.label.textContent = (xPos + yPos < 0) ? '' : `${xPos + 1} x ${yPos + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element} gridItem
|
||||
* @param {Element[]} gridItems
|
||||
* @return {{x: number, y: number}}
|
||||
*/
|
||||
getPositionOfGridItem(gridItem, gridItems) {
|
||||
const index = gridItems.indexOf(gridItem);
|
||||
const y = Math.floor(index / this.size);
|
||||
const x = index % this.size;
|
||||
return {x, y};
|
||||
}
|
||||
}
|
||||
|
||||
export default TableCreatorGrid;
|
||||
162
resources/js/editor/menu/icons.js
Normal file
162
resources/js/editor/menu/icons.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* This file originates from https://github.com/ProseMirror/prosemirror-menu
|
||||
* and is hence subject to the MIT license found here:
|
||||
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
|
||||
* @copyright Marijn Haverbeke and others
|
||||
*/
|
||||
|
||||
// :: Object
|
||||
// A set of basic editor-related icons. Contains the properties
|
||||
// `join`, `lift`, `selectParentNode`, `undo`, `redo`, `strong`, `em`,
|
||||
// `code`, `link`, `bulletList`, `orderedList`, and `blockquote`, each
|
||||
// holding an object that can be used as the `icon` option to
|
||||
// `MenuItem`.
|
||||
export const icons = {
|
||||
undo: {
|
||||
width: 24, height: 24,
|
||||
path: "M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"
|
||||
},
|
||||
redo: {
|
||||
width: 24, height: 24,
|
||||
path: "M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"
|
||||
},
|
||||
strong: {
|
||||
width: 24, height: 24,
|
||||
path: "M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"
|
||||
},
|
||||
em: {
|
||||
width: 24, height: 24,
|
||||
path: "M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"
|
||||
},
|
||||
link: {
|
||||
width: 24, height: 24,
|
||||
path: "M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"
|
||||
},
|
||||
bullet_list: {
|
||||
width: 24, height: 24,
|
||||
path: "M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"
|
||||
},
|
||||
ordered_list: {
|
||||
width: 24, height: 24,
|
||||
path: "M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"
|
||||
},
|
||||
task_list: {
|
||||
width: 24, height: 24,
|
||||
path: "M22,7h-9v2h9V7z M22,15h-9v2h9V15z M5.54,11L2,7.46l1.41-1.41l2.12,2.12l4.24-4.24l1.41,1.41L5.54,11z M5.54,19L2,15.46 l1.41-1.41l2.12,2.12l4.24-4.24l1.41,1.41L5.54,19z"
|
||||
},
|
||||
underline: {
|
||||
width: 24, height: 24,
|
||||
path: "M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z"
|
||||
},
|
||||
strike: {
|
||||
width: 24, height: 24,
|
||||
path: "M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"
|
||||
},
|
||||
superscript: {
|
||||
width: 24, height: 24,
|
||||
path: "M22,7h-2v1h3v1h-4V7c0-0.55,0.45-1,1-1h2V5h-3V4h3c0.55,0,1,0.45,1,1v1C23,6.55,22.55,7,22,7z M5.88,20h2.66l3.4-5.42h0.12 l3.4,5.42h2.66l-4.65-7.27L17.81,6h-2.68l-3.07,4.99h-0.12L8.85,6H6.19l4.32,6.73L5.88,20z"
|
||||
},
|
||||
subscript: {
|
||||
width: 24, height: 24,
|
||||
path: "M22,18h-2v1h3v1h-4v-2c0-0.55,0.45-1,1-1h2v-1h-3v-1h3c0.55,0,1,0.45,1,1v1C23,17.55,22.55,18,22,18z M5.88,18h2.66 l3.4-5.42h0.12l3.4,5.42h2.66l-4.65-7.27L17.81,4h-2.68l-3.07,4.99h-0.12L8.85,4H6.19l4.32,6.73L5.88,18z"
|
||||
},
|
||||
text_color: {
|
||||
width: 24, height: 24,
|
||||
path: "M2,20h20v4H2V20z M5.49,17h2.42l1.27-3.58h5.65L16.09,17h2.42L13.25,3h-2.5L5.49,17z M9.91,11.39l2.03-5.79h0.12l2.03,5.79 H9.91z"
|
||||
},
|
||||
background_color: {
|
||||
width: 24, height: 24,
|
||||
path: "M16.56,8.94L7.62,0L6.21,1.41l2.38,2.38L3.44,8.94c-0.59,0.59-0.59,1.54,0,2.12l5.5,5.5C9.23,16.85,9.62,17,10,17 s0.77-0.15,1.06-0.44l5.5-5.5C17.15,10.48,17.15,9.53,16.56,8.94z M5.21,10L10,5.21L14.79,10H5.21z M19,11.5c0,0-2,2.17-2,3.5 c0,1.1,0.9,2,2,2s2-0.9,2-2C21,13.67,19,11.5,19,11.5z M2,20h20v4H2V20z"
|
||||
},
|
||||
align_left: {
|
||||
width: 24, height: 24,
|
||||
path: "M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z"
|
||||
},
|
||||
align_right: {
|
||||
width: 24, height: 24,
|
||||
path: "M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z"
|
||||
},
|
||||
align_center: {
|
||||
width: 24, height: 24,
|
||||
path: "M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z"
|
||||
},
|
||||
align_justify: {
|
||||
width: 24, height: 24,
|
||||
path: "M3 21h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18V7H3v2zm0-6v2h18V3H3z"
|
||||
},
|
||||
horizontal_rule: {
|
||||
width: 24, height: 24,
|
||||
path: "m 4,11 h 16 v 2 H 4 Z"
|
||||
},
|
||||
format_clear: {
|
||||
width: 24, height: 24,
|
||||
path: "M3.27 5L2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21 18 19.73 3.55 5.27 3.27 5zM6 5v.18L8.82 8h2.4l-.72 1.68 2.1 2.1L14.21 8H20V5H6z"
|
||||
},
|
||||
close: {
|
||||
width: 24, height: 24,
|
||||
path: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z",
|
||||
},
|
||||
source_code: {
|
||||
width: 24, height: 24,
|
||||
path: "M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z",
|
||||
},
|
||||
table: {
|
||||
width: 24, height: 24,
|
||||
path: "M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 20H4v-4h4v4zm0-6H4v-4h4v4zm0-6H4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4z",
|
||||
},
|
||||
iframe: {
|
||||
width: 24, height: 24,
|
||||
path: "m 22.71,18.43 c 0.03,-0.29 0.04,-0.58 0.01,-0.86 l 1.07,-0.85 c 0.1,-0.08 0.12,-0.21 0.06,-0.32 L 22.82,14.61 C 22.76,14.5 22.63,14.46 22.51,14.5 L 21.23,15 C 21,14.83 20.75,14.69 20.48,14.58 l -0.2,-1.36 C 20.26,13.09 20.16,13 20.03,13 h -2.07 c -0.12,0 -0.23,0.09 -0.25,0.21 l -0.2,1.36 c -0.26,0.11 -0.51,0.26 -0.74,0.42 l -1.28,-0.5 c -0.12,-0.05 -0.25,0 -0.31,0.11 l -1.03,1.79 c -0.06,0.11 -0.04,0.24 0.06,0.32 l 1.07,0.86 c -0.03,0.29 -0.04,0.58 -0.01,0.86 l -1.07,0.85 c -0.1,0.08 -0.12,0.21 -0.06,0.32 l 1.03,1.79 c 0.06,0.11 0.19,0.15 0.31,0.11 L 16.75,21 c 0.23,0.17 0.48,0.31 0.75,0.42 l 0.2,1.36 c 0.02,0.12 0.12,0.21 0.25,0.21 h 2.07 c 0.12,0 0.23,-0.09 0.25,-0.21 l 0.2,-1.36 c 0.26,-0.11 0.51,-0.26 0.74,-0.42 l 1.28,0.5 c 0.12,0.05 0.25,0 0.31,-0.11 l 1.03,-1.79 c 0.06,-0.11 0.04,-0.24 -0.06,-0.32 z M 19,19.5 c -0.83,0 -1.5,-0.67 -1.5,-1.5 0,-0.83 0.67,-1.5 1.5,-1.5 0.83,0 1.5,0.67 1.5,1.5 0,0.83 -0.67,1.5 -1.5,1.5 z M 15,12 9,8 v 8 z M 3,6 h 18 v 5 h 2 V 6 C 23,4.9 22.1,4 21,4 H 3 C 1.9,4 1,4.9 1,6 v 12 c 0,1.1 0.9,2 2,2 h 9 V 18 H 3 Z",
|
||||
},
|
||||
details: {
|
||||
width: 24, height: 24,
|
||||
path: "m 7,10 5,5 5,-5 z M 19,2.5 H 5 c -1.11,0 -2,0.9 -2,2 v 14 c 0,1.1 0.89,2 2,2 h 14 c 1.1,0 2,-0.9 2,-2 v -14 c 0,-1.1 -0.89,-2 -2,-2 z m 0,16 H 5 v -12 h 14 z",
|
||||
}
|
||||
};
|
||||
|
||||
const SVG = "http://www.w3.org/2000/svg"
|
||||
const XLINK = "http://www.w3.org/1999/xlink"
|
||||
|
||||
const prefix = "ProseMirror-icon"
|
||||
|
||||
function hashPath(path) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < path.length; i++)
|
||||
hash = (((hash << 5) - hash) + path.charCodeAt(i)) | 0
|
||||
return hash
|
||||
}
|
||||
|
||||
export function getIcon(icon) {
|
||||
let node = document.createElement("div")
|
||||
node.className = prefix
|
||||
if (icon.path) {
|
||||
let name = "pm-icon-" + hashPath(icon.path).toString(16)
|
||||
if (!document.getElementById(name)) buildSVG(name, icon)
|
||||
let svg = node.appendChild(document.createElementNS(SVG, "svg"))
|
||||
svg.style.width = (icon.width / icon.height) + "em"
|
||||
let use = svg.appendChild(document.createElementNS(SVG, "use"))
|
||||
use.setAttributeNS(XLINK, "href", /([^#]*)/.exec(document.location)[1] + "#" + name)
|
||||
} else if (icon.dom) {
|
||||
node.appendChild(icon.dom.cloneNode(true))
|
||||
} else {
|
||||
node.appendChild(document.createElement("span")).textContent = icon.text || ''
|
||||
if (icon.css) node.firstChild.style.cssText = icon.css
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function buildSVG(name, data) {
|
||||
let collection = document.getElementById(prefix + "-collection")
|
||||
if (!collection) {
|
||||
collection = document.createElementNS(SVG, "svg")
|
||||
collection.id = prefix + "-collection"
|
||||
collection.style.display = "none"
|
||||
document.body.insertBefore(collection, document.body.firstChild)
|
||||
}
|
||||
let sym = document.createElementNS(SVG, "symbol")
|
||||
sym.id = name
|
||||
sym.setAttribute("viewBox", "0 0 " + data.width + " " + data.height)
|
||||
let path = sym.appendChild(document.createElementNS(SVG, "path"))
|
||||
path.setAttribute("d", data.path)
|
||||
collection.appendChild(sym)
|
||||
}
|
||||
207
resources/js/editor/menu/index.js
Normal file
207
resources/js/editor/menu/index.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
MenuItem, Dropdown, DropdownSubmenu, renderGrouped, joinUpItem, liftItem, selectParentNodeItem,
|
||||
undoItem, redoItem, wrapItem, blockTypeItem, setAttrItem, insertBlockBeforeItem,
|
||||
} from "./menu"
|
||||
import {icons} from "./icons";
|
||||
import ColorPickerGrid from "./ColorPickerGrid";
|
||||
import TableCreatorGrid from "./TableCreatorGrid";
|
||||
import {toggleMark} from "prosemirror-commands";
|
||||
import {menuBar} from "./menubar"
|
||||
import schema from "../schema";
|
||||
import {removeMarks} from "../commands";
|
||||
|
||||
import itemAnchorButtonItem from "./item-anchor-button";
|
||||
import itemHtmlSourceButton from "./item-html-source-button";
|
||||
import itemIframeButton from "./item-iframe-button";
|
||||
|
||||
|
||||
function cmdItem(cmd, options) {
|
||||
const passedOptions = {
|
||||
label: options.title,
|
||||
run: cmd
|
||||
};
|
||||
for (const prop in options) {
|
||||
passedOptions[prop] = options[prop];
|
||||
}
|
||||
if ((!options.enable || options.enable === true) && !options.select) {
|
||||
passedOptions[options.enable ? "enable" : "select"] = function (state) {
|
||||
return cmd(state);
|
||||
};
|
||||
}
|
||||
|
||||
return new MenuItem(passedOptions)
|
||||
}
|
||||
|
||||
function markActive(state, type) {
|
||||
const ref = state.selection;
|
||||
const from = ref.from;
|
||||
const $from = ref.$from;
|
||||
const to = ref.to;
|
||||
const empty = ref.empty;
|
||||
if (empty) {
|
||||
return type.isInSet(state.storedMarks || $from.marks())
|
||||
} else {
|
||||
return state.doc.rangeHasMark(from, to, type)
|
||||
}
|
||||
}
|
||||
|
||||
function markItem(markType, options) {
|
||||
const passedOptions = {
|
||||
active: function active(state) {
|
||||
return markActive(state, markType)
|
||||
},
|
||||
enable: true
|
||||
};
|
||||
for (const prop in options) {
|
||||
passedOptions[prop] = options[prop];
|
||||
}
|
||||
|
||||
return cmdItem(toggleMark(markType, passedOptions.attrs), passedOptions)
|
||||
}
|
||||
|
||||
const inlineStyles = [
|
||||
markItem(schema.marks.strong, {title: "Bold", icon: icons.strong}),
|
||||
markItem(schema.marks.em, {title: "Italic", icon: icons.em}),
|
||||
markItem(schema.marks.underline, {title: "Underline", icon: icons.underline}),
|
||||
markItem(schema.marks.strike, {title: "Strikethrough", icon: icons.strike}),
|
||||
markItem(schema.marks.superscript, {title: "Superscript", icon: icons.superscript}),
|
||||
markItem(schema.marks.subscript, {title: "Subscript", icon: icons.subscript}),
|
||||
];
|
||||
|
||||
const formats = [
|
||||
blockTypeItem(schema.nodes.heading, {
|
||||
label: "Header Large",
|
||||
attrs: {level: 2}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.heading, {
|
||||
label: "Header Medium",
|
||||
attrs: {level: 3}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.heading, {
|
||||
label: "Header Small",
|
||||
attrs: {level: 4}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.heading, {
|
||||
label: "Header Tiny",
|
||||
attrs: {level: 5}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.paragraph, {
|
||||
label: "Paragraph",
|
||||
attrs: {}
|
||||
}),
|
||||
markItem(schema.marks.code, {
|
||||
label: "Inline Code",
|
||||
attrs: {}
|
||||
}),
|
||||
new DropdownSubmenu([
|
||||
blockTypeItem(schema.nodes.callout, {
|
||||
label: "Info Callout",
|
||||
attrs: {type: 'info'}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.callout, {
|
||||
label: "Danger Callout",
|
||||
attrs: {type: 'danger'}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.callout, {
|
||||
label: "Success Callout",
|
||||
attrs: {type: 'success'}
|
||||
}),
|
||||
blockTypeItem(schema.nodes.callout, {
|
||||
label: "Warning Callout",
|
||||
attrs: {type: 'warning'}
|
||||
})
|
||||
], { label: 'Callouts' }),
|
||||
];
|
||||
|
||||
const alignments = [
|
||||
setAttrItem('align', 'left', {
|
||||
icon: icons.align_left
|
||||
}),
|
||||
setAttrItem('align', 'center', {
|
||||
icon: icons.align_center
|
||||
}),
|
||||
setAttrItem('align', 'right', {
|
||||
icon: icons.align_right
|
||||
}),
|
||||
setAttrItem('align', 'justify', {
|
||||
icon: icons.align_justify
|
||||
}),
|
||||
];
|
||||
|
||||
const colorOptions = ["#000000","#993300","#333300","#003300","#003366","#000080","#333399","#333333","#800000","#FF6600","#808000","#008000","#008080","#0000FF","#666699","#808080","#FF0000","#FF9900","#99CC00","#339966","#33CCCC","#3366FF","#800080","#999999","#FF00FF","#FFCC00","#FFFF00","#00FF00","#00FFFF","#00CCFF","#993366","#FFFFFF","#FF99CC","#FFCC99","#FFFF99","#CCFFCC","#CCFFFF","#99CCFF","#CC99FF"];
|
||||
|
||||
const colors = [
|
||||
new DropdownSubmenu([
|
||||
new ColorPickerGrid(schema.marks.text_color, 'color', colorOptions),
|
||||
], {icon: icons.text_color}),
|
||||
new DropdownSubmenu([
|
||||
new ColorPickerGrid(schema.marks.background_color, 'color', colorOptions),
|
||||
], {icon: icons.background_color}),
|
||||
];
|
||||
|
||||
const lists = [
|
||||
wrapItem(schema.nodes.bullet_list, {
|
||||
title: "Bullet List",
|
||||
icon: icons.bullet_list,
|
||||
}),
|
||||
wrapItem(schema.nodes.ordered_list, {
|
||||
title: "Ordered List",
|
||||
icon: icons.ordered_list,
|
||||
}),
|
||||
];
|
||||
|
||||
const inserts = [
|
||||
itemAnchorButtonItem(),
|
||||
insertBlockBeforeItem(schema.nodes.horizontal_rule, {
|
||||
title: "Horizontal Rule",
|
||||
icon: icons.horizontal_rule,
|
||||
}),
|
||||
new DropdownSubmenu([
|
||||
new TableCreatorGrid()
|
||||
], {icon: icons.table}),
|
||||
itemIframeButton(),
|
||||
wrapItem(schema.nodes.details, {
|
||||
title: "Dropdown Block",
|
||||
icon: icons.details,
|
||||
})
|
||||
];
|
||||
|
||||
const utilities = [
|
||||
new MenuItem({
|
||||
title: 'Clear Formatting',
|
||||
icon: icons.format_clear,
|
||||
run: removeMarks(),
|
||||
enable: state => true,
|
||||
}),
|
||||
itemHtmlSourceButton(),
|
||||
];
|
||||
|
||||
const menu = menuBar({
|
||||
floating: false,
|
||||
content: [
|
||||
[undoItem, redoItem],
|
||||
[new DropdownSubmenu(formats, { label: 'Formats' })],
|
||||
inlineStyles,
|
||||
colors,
|
||||
alignments,
|
||||
lists,
|
||||
inserts,
|
||||
utilities,
|
||||
],
|
||||
});
|
||||
|
||||
export default menu;
|
||||
|
||||
// !! This module defines a number of building blocks for ProseMirror
|
||||
// menus, along with a [menu bar](#menu.menuBar) implementation.
|
||||
|
||||
// MenuElement:: interface
|
||||
// The types defined in this module aren't the only thing you can
|
||||
// display in your menu. Anything that conforms to this interface can
|
||||
// be put into a menu structure.
|
||||
//
|
||||
// render:: (pm: EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Render the element for display in the menu. Must return a DOM
|
||||
// element and a function that can be used to update the element to
|
||||
// a new state. The `update` function will return false if the
|
||||
// update hid the entire element.
|
||||
120
resources/js/editor/menu/item-anchor-button.js
Normal file
120
resources/js/editor/menu/item-anchor-button.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import DialogBox from "./DialogBox";
|
||||
import DialogForm from "./DialogForm";
|
||||
import DialogInput from "./DialogInput";
|
||||
import DialogRadioOptions from "./DialogRadioOptions";
|
||||
import schema from "../schema";
|
||||
|
||||
import {MenuItem} from "./menu";
|
||||
import {icons} from "./icons";
|
||||
import {expandSelectionToMark, nullifyEmptyValues} from "../util";
|
||||
|
||||
/**
|
||||
* @param {PmMarkType} markType
|
||||
* @param {String} attribute
|
||||
* @return {(function(PmEditorState): (string|null))}
|
||||
*/
|
||||
function getMarkAttribute(markType, attribute) {
|
||||
return function (state) {
|
||||
const marks = state.selection.$head.marks();
|
||||
for (const mark of marks) {
|
||||
if (mark.type === markType) {
|
||||
return mark.attrs[attribute];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(function(FormData))} submitter
|
||||
* @param {Function} closer
|
||||
* @return {DialogBox}
|
||||
*/
|
||||
function getLinkDialog(submitter, closer) {
|
||||
return new DialogBox([
|
||||
new DialogForm([
|
||||
new DialogInput({
|
||||
label: 'URL',
|
||||
id: 'href',
|
||||
value: getMarkAttribute(schema.marks.link, 'href'),
|
||||
}),
|
||||
new DialogInput({
|
||||
label: 'Hover Label',
|
||||
id: 'title',
|
||||
value: getMarkAttribute(schema.marks.link, 'title'),
|
||||
}),
|
||||
new DialogRadioOptions({
|
||||
"Same tab or window": "",
|
||||
"New tab or window": "_blank",
|
||||
}, {
|
||||
label: 'Behaviour',
|
||||
id: 'target',
|
||||
value: getMarkAttribute(schema.marks.link, 'target'),
|
||||
})
|
||||
], {
|
||||
canceler: closer,
|
||||
action: submitter,
|
||||
}),
|
||||
], {
|
||||
label: 'Insert Link',
|
||||
closer: closer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FormData} formData
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @return {boolean}
|
||||
*/
|
||||
function applyLink(formData, state, dispatch) {
|
||||
const selection = state.selection;
|
||||
const attrs = nullifyEmptyValues(Object.fromEntries(formData));
|
||||
if (!dispatch) return true;
|
||||
|
||||
const tr = state.tr;
|
||||
const {from, to} = expandSelectionToMark(state, selection, schema.marks.link);
|
||||
|
||||
if (attrs.href) {
|
||||
tr.addMark(from, to, schema.marks.link.create(attrs));
|
||||
} else {
|
||||
tr.removeMark(from, to, schema.marks.link);
|
||||
}
|
||||
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @param {PmView} view
|
||||
* @param {Event} e
|
||||
*/
|
||||
function onPress(state, dispatch, view, e) {
|
||||
const dialog = getLinkDialog((data) => {
|
||||
applyLink(data, state, dispatch);
|
||||
dom.remove();
|
||||
}, () => {
|
||||
dom.remove();
|
||||
})
|
||||
|
||||
const {dom, update} = dialog.render(view);
|
||||
update(state);
|
||||
document.body.appendChild(dom);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {MenuItem}
|
||||
*/
|
||||
function anchorButtonItem() {
|
||||
return new MenuItem({
|
||||
title: "Insert/Edit Anchor Link",
|
||||
run: onPress,
|
||||
enable: state => true,
|
||||
icon: icons.link,
|
||||
});
|
||||
}
|
||||
|
||||
export default anchorButtonItem;
|
||||
87
resources/js/editor/menu/item-html-source-button.js
Normal file
87
resources/js/editor/menu/item-html-source-button.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import DialogBox from "./DialogBox";
|
||||
import DialogForm from "./DialogForm";
|
||||
import DialogTextArea from "./DialogTextArea";
|
||||
|
||||
import {MenuItem} from "./menu";
|
||||
import {icons} from "./icons";
|
||||
import {htmlToDoc, stateToHtml} from "../util";
|
||||
|
||||
/**
|
||||
* @param {(function(FormData))} submitter
|
||||
* @param {Function} closer
|
||||
* @return {DialogBox}
|
||||
*/
|
||||
function getLinkDialog(submitter, closer) {
|
||||
return new DialogBox([
|
||||
new DialogForm([
|
||||
new DialogTextArea({
|
||||
id: 'source',
|
||||
value: stateToHtml,
|
||||
attrs: {
|
||||
rows: 10,
|
||||
cols: 50,
|
||||
}
|
||||
}),
|
||||
], {
|
||||
canceler: closer,
|
||||
action: submitter,
|
||||
}),
|
||||
], {
|
||||
label: 'View/Edit HTML Source',
|
||||
closer: closer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FormData} formData
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @return {boolean}
|
||||
*/
|
||||
function replaceEditorHtml(formData, state, dispatch) {
|
||||
const html = formData.get('source');
|
||||
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
|
||||
const newDoc = htmlToDoc(html);
|
||||
tr.replaceWith(0, state.doc.content.size, newDoc.content);
|
||||
dispatch(tr);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @param {PmView} view
|
||||
* @param {Event} e
|
||||
*/
|
||||
function onPress(state, dispatch, view, e) {
|
||||
const dialog = getLinkDialog((data) => {
|
||||
replaceEditorHtml(data, state, dispatch);
|
||||
dom.remove();
|
||||
}, () => {
|
||||
dom.remove();
|
||||
})
|
||||
|
||||
const {dom, update} = dialog.render(view);
|
||||
update(state);
|
||||
document.body.appendChild(dom);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {MenuItem}
|
||||
*/
|
||||
function htmlSourceButtonItem() {
|
||||
return new MenuItem({
|
||||
title: "View HTML Source",
|
||||
run: onPress,
|
||||
enable: state => true,
|
||||
icon: icons.source_code,
|
||||
});
|
||||
}
|
||||
|
||||
export default htmlSourceButtonItem;
|
||||
115
resources/js/editor/menu/item-iframe-button.js
Normal file
115
resources/js/editor/menu/item-iframe-button.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import DialogBox from "./DialogBox";
|
||||
import DialogForm from "./DialogForm";
|
||||
import DialogInput from "./DialogInput";
|
||||
import schema from "../schema";
|
||||
|
||||
import {MenuItem} from "./menu";
|
||||
import {icons} from "./icons";
|
||||
import {nullifyEmptyValues} from "../util";
|
||||
|
||||
/**
|
||||
* @param {PmNodeType} nodeType
|
||||
* @param {String} attribute
|
||||
* @return {(function(PmEditorState): (string|null))}
|
||||
*/
|
||||
function getNodeAttribute(nodeType, attribute) {
|
||||
return function (state) {
|
||||
const node = state.selection.node;
|
||||
if (node && node.type === nodeType) {
|
||||
return node.attrs[attribute];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(function(FormData))} submitter
|
||||
* @param {Function} closer
|
||||
* @return {DialogBox}
|
||||
*/
|
||||
function getLinkDialog(submitter, closer) {
|
||||
return new DialogBox([
|
||||
new DialogForm([
|
||||
new DialogInput({
|
||||
label: 'Source URL',
|
||||
id: 'src',
|
||||
value: getNodeAttribute(schema.nodes.iframe, 'src'),
|
||||
}),
|
||||
new DialogInput({
|
||||
label: 'Hover Label',
|
||||
id: 'title',
|
||||
value: getNodeAttribute(schema.nodes.iframe, 'title'),
|
||||
}),
|
||||
new DialogInput({
|
||||
label: 'Width',
|
||||
id: 'width',
|
||||
value: getNodeAttribute(schema.nodes.iframe, 'width'),
|
||||
}),
|
||||
new DialogInput({
|
||||
label: 'Height',
|
||||
id: 'height',
|
||||
value: getNodeAttribute(schema.nodes.iframe, 'height'),
|
||||
}),
|
||||
], {
|
||||
canceler: closer,
|
||||
action: submitter,
|
||||
}),
|
||||
], {
|
||||
label: 'Insert Embedded Content',
|
||||
closer: closer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FormData} formData
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @return {boolean}
|
||||
*/
|
||||
function applyIframe(formData, state, dispatch) {
|
||||
const attrs = nullifyEmptyValues(Object.fromEntries(formData));
|
||||
if (!dispatch) return true;
|
||||
|
||||
const tr = state.tr;
|
||||
const currentNodeAttrs = state.selection?.nodes?.attrs || {};
|
||||
const newAttrs = Object.assign({}, currentNodeAttrs, attrs);
|
||||
tr.replaceSelectionWith(schema.nodes.iframe.create(newAttrs));
|
||||
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
* @param {PmView} view
|
||||
* @param {Event} e
|
||||
*/
|
||||
function onPress(state, dispatch, view, e) {
|
||||
const dialog = getLinkDialog((data) => {
|
||||
applyIframe(data, state, dispatch);
|
||||
dom.remove();
|
||||
}, () => {
|
||||
dom.remove();
|
||||
})
|
||||
|
||||
const {dom, update} = dialog.render(view);
|
||||
update(state);
|
||||
document.body.appendChild(dom);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {MenuItem}
|
||||
*/
|
||||
function iframeButtonItem() {
|
||||
return new MenuItem({
|
||||
title: "Embed Content",
|
||||
run: onPress,
|
||||
enable: state => true,
|
||||
active: state => (state.selection.node || {type: ''}).type === schema.nodes.iframe,
|
||||
icon: icons.iframe,
|
||||
});
|
||||
}
|
||||
|
||||
export default iframeButtonItem;
|
||||
39
resources/js/editor/menu/menu-utils.js
Normal file
39
resources/js/editor/menu/menu-utils.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import crel from "crelt";
|
||||
|
||||
export const prefix = "ProseMirror-menu";
|
||||
|
||||
export function renderDropdownItems(items, view) {
|
||||
let rendered = [], updates = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let {dom, update} = items[i].render(view)
|
||||
rendered.push(crel("div", {class: prefix + "-dropdown-item"}, dom))
|
||||
updates.push(update)
|
||||
}
|
||||
return {dom: rendered, update: combineUpdates(updates, rendered)}
|
||||
}
|
||||
|
||||
export function renderItems(items, view) {
|
||||
let rendered = [], updates = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let {dom, update} = items[i].render(view)
|
||||
rendered.push(dom);
|
||||
updates.push(update)
|
||||
}
|
||||
return {dom: rendered, update: combineUpdates(updates, rendered)}
|
||||
}
|
||||
|
||||
export function combineUpdates(updates, nodes) {
|
||||
return state => {
|
||||
let something = false
|
||||
for (let i = 0; i < updates.length; i++) {
|
||||
let up = updates[i](state)
|
||||
nodes[i].style.display = up ? "" : "none"
|
||||
if (up) something = true
|
||||
}
|
||||
return something
|
||||
}
|
||||
}
|
||||
|
||||
export function randHtmlId() {
|
||||
return Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 9);
|
||||
}
|
||||
419
resources/js/editor/menu/menu.js
Normal file
419
resources/js/editor/menu/menu.js
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* This file originates from https://github.com/ProseMirror/prosemirror-menu
|
||||
* and is hence subject to the MIT license found here:
|
||||
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
|
||||
* @copyright Marijn Haverbeke and others
|
||||
*/
|
||||
|
||||
import crel from "crelt"
|
||||
import {lift, joinUp, selectParentNode, wrapIn, setBlockType, toggleMark} from "prosemirror-commands"
|
||||
import {undo, redo} from "prosemirror-history"
|
||||
import {setBlockAttr, insertBlockBefore} from "../commands";
|
||||
import {renderDropdownItems, combineUpdates} from "./menu-utils";
|
||||
|
||||
import {getIcon, icons} from "./icons"
|
||||
import {prefix} from "./menu-utils";
|
||||
|
||||
// ::- An icon or label that, when clicked, executes a command.
|
||||
export class MenuItem {
|
||||
// :: (MenuItemSpec)
|
||||
constructor(spec) {
|
||||
// :: MenuItemSpec
|
||||
// The spec used to create the menu item.
|
||||
this.spec = spec
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the icon according to its [display
|
||||
// spec](#menu.MenuItemSpec.display), and adds an event handler which
|
||||
// executes the command when the representation is clicked.
|
||||
render(view) {
|
||||
let spec = this.spec
|
||||
let dom = spec.render ? spec.render(view)
|
||||
: spec.icon ? getIcon(spec.icon)
|
||||
: spec.label ? crel("div", null, translate(view, spec.label))
|
||||
: null
|
||||
if (!dom) throw new RangeError("MenuItem without icon or label property")
|
||||
if (spec.title) {
|
||||
const title = (typeof spec.title === "function" ? spec.title(view.state) : spec.title)
|
||||
dom.setAttribute("title", translate(view, title))
|
||||
}
|
||||
if (spec.class) dom.classList.add(spec.class)
|
||||
if (spec.css) dom.style.cssText += spec.css
|
||||
|
||||
dom.addEventListener("mousedown", e => {
|
||||
e.preventDefault()
|
||||
if (!dom.classList.contains(prefix + "-disabled"))
|
||||
spec.run(view.state, view.dispatch, view, e)
|
||||
})
|
||||
|
||||
function update(state) {
|
||||
if (spec.select) {
|
||||
let selected = spec.select(state)
|
||||
dom.style.display = selected ? "" : "none"
|
||||
if (!selected) return false
|
||||
}
|
||||
let enabled = true
|
||||
if (spec.enable) {
|
||||
enabled = spec.enable(state) || false
|
||||
setClass(dom, prefix + "-disabled", !enabled)
|
||||
}
|
||||
if (spec.active) {
|
||||
let active = enabled && spec.active(state) || false
|
||||
setClass(dom, prefix + "-active", active)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return {dom, update}
|
||||
}
|
||||
}
|
||||
|
||||
function translate(view, text) {
|
||||
return view._props.translate ? view._props.translate(text) : text
|
||||
}
|
||||
|
||||
// MenuItemSpec:: interface
|
||||
// The configuration object passed to the `MenuItem` constructor.
|
||||
//
|
||||
// run:: (EditorState, (Transaction), EditorView, dom.Event)
|
||||
// The function to execute when the menu item is activated.
|
||||
//
|
||||
// select:: ?(EditorState) → bool
|
||||
// Optional function that is used to determine whether the item is
|
||||
// appropriate at the moment. Deselected items will be hidden.
|
||||
//
|
||||
// enable:: ?(EditorState) → bool
|
||||
// Function that is used to determine if the item is enabled. If
|
||||
// given and returning false, the item will be given a disabled
|
||||
// styling.
|
||||
//
|
||||
// active:: ?(EditorState) → bool
|
||||
// A predicate function to determine whether the item is 'active' (for
|
||||
// example, the item for toggling the strong mark might be active then
|
||||
// the cursor is in strong text).
|
||||
//
|
||||
// render:: ?(EditorView) → dom.Node
|
||||
// A function that renders the item. You must provide either this,
|
||||
// [`icon`](#menu.MenuItemSpec.icon), or [`label`](#MenuItemSpec.label).
|
||||
//
|
||||
// icon:: ?Object
|
||||
// Describes an icon to show for this item. The object may specify
|
||||
// an SVG icon, in which case its `path` property should be an [SVG
|
||||
// path
|
||||
// spec](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d),
|
||||
// and `width` and `height` should provide the viewbox in which that
|
||||
// path exists. Alternatively, it may have a `text` property
|
||||
// specifying a string of text that makes up the icon, with an
|
||||
// optional `css` property giving additional CSS styling for the
|
||||
// text. _Or_ it may contain `dom` property containing a DOM node.
|
||||
//
|
||||
// label:: ?string
|
||||
// Makes the item show up as a text label. Mostly useful for items
|
||||
// wrapped in a [drop-down](#menu.Dropdown) or similar menu. The object
|
||||
// should have a `label` property providing the text to display.
|
||||
//
|
||||
// title:: ?union<string, (EditorState) → string>
|
||||
// Defines DOM title (mouseover) text for the item.
|
||||
//
|
||||
// class:: ?string
|
||||
// Optionally adds a CSS class to the item's DOM representation.
|
||||
//
|
||||
// css:: ?string
|
||||
// Optionally adds a string of inline CSS to the item's DOM
|
||||
// representation.
|
||||
|
||||
let lastMenuEvent = {time: 0, node: null}
|
||||
function markMenuEvent(e) {
|
||||
lastMenuEvent.time = Date.now()
|
||||
lastMenuEvent.node = e.target
|
||||
}
|
||||
function isMenuEvent(wrapper) {
|
||||
return Date.now() - 100 < lastMenuEvent.time &&
|
||||
lastMenuEvent.node && wrapper.contains(lastMenuEvent.node)
|
||||
}
|
||||
|
||||
// ::- A drop-down menu, displayed as a label with a downwards-pointing
|
||||
// triangle to the right of it.
|
||||
export class Dropdown {
|
||||
// :: ([MenuElement], ?Object)
|
||||
// Create a dropdown wrapping the elements. Options may include
|
||||
// the following properties:
|
||||
//
|
||||
// **`label`**`: string`
|
||||
// : The label to show on the drop-down control.
|
||||
//
|
||||
// **`title`**`: string`
|
||||
// : Sets the
|
||||
// [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title)
|
||||
// attribute given to the menu control.
|
||||
//
|
||||
// **`class`**`: string`
|
||||
// : When given, adds an extra CSS class to the menu control.
|
||||
//
|
||||
// **`css`**`: string`
|
||||
// : When given, adds an extra set of CSS styles to the menu control.
|
||||
constructor(content, options) {
|
||||
this.options = options || {}
|
||||
this.content = Array.isArray(content) ? content : [content]
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState)}
|
||||
// Render the dropdown menu and sub-items.
|
||||
render(view) {
|
||||
let content = renderDropdownItems(this.content, view)
|
||||
|
||||
let label = crel("div", {class: prefix + "-dropdown " + (this.options.class || ""),
|
||||
style: this.options.css},
|
||||
translate(view, this.options.label))
|
||||
if (this.options.title) label.setAttribute("title", translate(view, this.options.title))
|
||||
let wrap = crel("div", {class: prefix + "-dropdown-wrap"}, label)
|
||||
let open = null, listeningOnClose = null
|
||||
let close = () => {
|
||||
if (open && open.close()) {
|
||||
open = null
|
||||
window.removeEventListener("mousedown", listeningOnClose)
|
||||
}
|
||||
}
|
||||
label.addEventListener("mousedown", e => {
|
||||
e.preventDefault()
|
||||
markMenuEvent(e)
|
||||
if (open) {
|
||||
close()
|
||||
} else {
|
||||
open = this.expand(wrap, content.dom)
|
||||
window.addEventListener("mousedown", listeningOnClose = () => {
|
||||
if (!isMenuEvent(wrap)) close()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function update(state) {
|
||||
let inner = content.update(state)
|
||||
wrap.style.display = inner ? "" : "none"
|
||||
return inner
|
||||
}
|
||||
|
||||
return {dom: wrap, update}
|
||||
}
|
||||
|
||||
expand(dom, items) {
|
||||
let menuDOM = crel("div", {class: prefix + "-dropdown-menu " + (this.options.class || "")}, items)
|
||||
|
||||
let done = false
|
||||
function close() {
|
||||
if (done) return
|
||||
done = true
|
||||
dom.removeChild(menuDOM)
|
||||
return true
|
||||
}
|
||||
dom.appendChild(menuDOM)
|
||||
return {close, node: menuDOM}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ::- Represents a submenu wrapping a group of elements that start
|
||||
// hidden and expand to the right when hovered over or tapped.
|
||||
export class DropdownSubmenu {
|
||||
// :: ([MenuElement], ?Object)
|
||||
// Creates a submenu for the given group of menu elements. The
|
||||
// following options are recognized:
|
||||
//
|
||||
// **`label`**`: string`
|
||||
// : The label to show on the submenu.
|
||||
constructor(content, options) {
|
||||
this.options = options || {}
|
||||
this.content = Array.isArray(content) ? content : [content]
|
||||
}
|
||||
|
||||
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
|
||||
// Renders the submenu.
|
||||
render(view) {
|
||||
const items = renderDropdownItems(this.content, view)
|
||||
|
||||
const handleContent = this.options.icon ? getIcon(this.options.icon) : crel("div", {class: prefix + "-submenu-label"}, translate(view, this.options.label));
|
||||
const wrap = crel("div", {class: prefix + "-submenu-wrap"}, handleContent,
|
||||
crel("div", {class: prefix + "-submenu"}, items.dom))
|
||||
let listeningOnClose = null
|
||||
handleContent.addEventListener("mousedown", e => {
|
||||
e.preventDefault()
|
||||
markMenuEvent(e)
|
||||
setClass(wrap, prefix + "-submenu-wrap-active")
|
||||
if (!listeningOnClose)
|
||||
window.addEventListener("mousedown", listeningOnClose = () => {
|
||||
if (!isMenuEvent(wrap)) {
|
||||
wrap.classList.remove(prefix + "-submenu-wrap-active")
|
||||
window.removeEventListener("mousedown", listeningOnClose)
|
||||
listeningOnClose = null
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function update(state) {
|
||||
let inner = items.update(state)
|
||||
wrap.style.display = inner ? "" : "none"
|
||||
return inner
|
||||
}
|
||||
return {dom: wrap, update}
|
||||
}
|
||||
}
|
||||
|
||||
// :: (EditorView, [[MenuElement]]) → {dom: dom.DocumentFragment, update: (EditorState) → bool}
|
||||
// Render the given, possibly nested, array of menu elements into a
|
||||
// document fragment, placing separators between them (and ensuring no
|
||||
// superfluous separators appear when some of the groups turn out to
|
||||
// be empty).
|
||||
export function renderGrouped(view, content) {
|
||||
let result = document.createDocumentFragment()
|
||||
let updates = [], separators = []
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
let items = content[i], localUpdates = [], localNodes = []
|
||||
for (let j = 0; j < items.length; j++) {
|
||||
let {dom, update} = items[j].render(view)
|
||||
let span = crel("span", {class: prefix + "item"}, dom)
|
||||
result.appendChild(span)
|
||||
localNodes.push(span)
|
||||
localUpdates.push(update)
|
||||
}
|
||||
if (localUpdates.length) {
|
||||
updates.push(combineUpdates(localUpdates, localNodes))
|
||||
if (i < content.length - 1)
|
||||
separators.push(result.appendChild(separator()))
|
||||
}
|
||||
}
|
||||
|
||||
function update(state) {
|
||||
let something = false, needSep = false
|
||||
for (let i = 0; i < updates.length; i++) {
|
||||
let hasContent = updates[i](state)
|
||||
if (i) separators[i - 1].style.display = needSep && hasContent ? "" : "none"
|
||||
needSep = hasContent
|
||||
if (hasContent) something = true
|
||||
}
|
||||
return something
|
||||
}
|
||||
return {dom: result, update}
|
||||
}
|
||||
|
||||
function separator() {
|
||||
return crel("span", {class: prefix + "separator"})
|
||||
}
|
||||
|
||||
|
||||
// :: MenuItem
|
||||
// Menu item for the `joinUp` command.
|
||||
export const joinUpItem = new MenuItem({
|
||||
title: "Join with above block",
|
||||
run: joinUp,
|
||||
select: state => joinUp(state),
|
||||
icon: icons.join
|
||||
})
|
||||
|
||||
// :: MenuItem
|
||||
// Menu item for the `lift` command.
|
||||
export const liftItem = new MenuItem({
|
||||
title: "Lift out of enclosing block",
|
||||
run: lift,
|
||||
select: state => lift(state),
|
||||
icon: icons.lift
|
||||
})
|
||||
|
||||
// :: MenuItem
|
||||
// Menu item for the `selectParentNode` command.
|
||||
export const selectParentNodeItem = new MenuItem({
|
||||
title: "Select parent node",
|
||||
run: selectParentNode,
|
||||
select: state => selectParentNode(state),
|
||||
icon: icons.selectParentNode
|
||||
})
|
||||
|
||||
// :: MenuItem
|
||||
// Menu item for the `undo` command.
|
||||
export let undoItem = new MenuItem({
|
||||
title: "Undo last change",
|
||||
run: undo,
|
||||
enable: state => undo(state),
|
||||
icon: icons.undo
|
||||
})
|
||||
|
||||
// :: MenuItem
|
||||
// Menu item for the `redo` command.
|
||||
export let redoItem = new MenuItem({
|
||||
title: "Redo last undone change",
|
||||
run: redo,
|
||||
enable: state => redo(state),
|
||||
icon: icons.redo
|
||||
})
|
||||
|
||||
// :: (NodeType, Object) → MenuItem
|
||||
// Build a menu item for wrapping the selection in a given node type.
|
||||
// Adds `run` and `select` properties to the ones present in
|
||||
// `options`. `options.attrs` may be an object or a function.
|
||||
export function wrapItem(nodeType, options) {
|
||||
let passedOptions = {
|
||||
run(state, dispatch) {
|
||||
// FIXME if (options.attrs instanceof Function) options.attrs(state, attrs => wrapIn(nodeType, attrs)(state))
|
||||
return wrapIn(nodeType, options.attrs)(state, dispatch)
|
||||
},
|
||||
select(state) {
|
||||
return wrapIn(nodeType, options.attrs instanceof Function ? null : options.attrs)(state)
|
||||
}
|
||||
}
|
||||
for (let prop in options) passedOptions[prop] = options[prop]
|
||||
return new MenuItem(passedOptions)
|
||||
}
|
||||
|
||||
// :: (NodeType, Object) → MenuItem
|
||||
// Build a menu item for changing the type of the textblock around the
|
||||
// selection to the given type. Provides `run`, `active`, and `select`
|
||||
// properties. Others must be given in `options`. `options.attrs` may
|
||||
// be an object to provide the attributes for the textblock node.
|
||||
export function blockTypeItem(nodeType, options) {
|
||||
let command = setBlockType(nodeType, options.attrs)
|
||||
let passedOptions = {
|
||||
run: command,
|
||||
enable(state) { return command(state) },
|
||||
active(state) {
|
||||
let {$from, to, node} = state.selection
|
||||
if (node) return node.hasMarkup(nodeType, options.attrs)
|
||||
return to <= $from.end() && $from.parent.hasMarkup(nodeType, options.attrs)
|
||||
}
|
||||
}
|
||||
for (let prop in options) passedOptions[prop] = options[prop]
|
||||
return new MenuItem(passedOptions)
|
||||
}
|
||||
|
||||
export function setAttrItem(attrName, attrValue, options) {
|
||||
const command = setBlockAttr(attrName, attrValue);
|
||||
const passedOptions = {
|
||||
run: command,
|
||||
enable(state) { return command(state) },
|
||||
active(state) {
|
||||
const {$from, to, node} = state.selection
|
||||
if (node) return node.attrs[attrValue] === attrValue;
|
||||
return to <= $from.end() && $from.parent.attrs[attrValue] === attrValue;
|
||||
}
|
||||
}
|
||||
for (const prop in options) passedOptions[prop] = options[prop]
|
||||
return new MenuItem(passedOptions)
|
||||
}
|
||||
|
||||
export function insertBlockBeforeItem(blockType, options) {
|
||||
const command = insertBlockBefore(blockType);
|
||||
const passedOptions = {
|
||||
run: command,
|
||||
enable(state) { return command(state) },
|
||||
active(state) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const prop in options) passedOptions[prop] = options[prop]
|
||||
return new MenuItem(passedOptions);
|
||||
}
|
||||
|
||||
// Work around classList.toggle being broken in IE11
|
||||
function setClass(dom, cls, on) {
|
||||
if (on) dom.classList.add(cls)
|
||||
else dom.classList.remove(cls)
|
||||
}
|
||||
163
resources/js/editor/menu/menubar.js
Normal file
163
resources/js/editor/menu/menubar.js
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* This file originates from https://github.com/ProseMirror/prosemirror-menu
|
||||
* and is hence subject to the MIT license found here:
|
||||
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
|
||||
* @copyright Marijn Haverbeke and others
|
||||
*/
|
||||
|
||||
import crel from "crelt"
|
||||
import {Plugin} from "prosemirror-state"
|
||||
|
||||
import {renderGrouped} from "./menu"
|
||||
|
||||
const prefix = "ProseMirror-menubar"
|
||||
|
||||
function isIOS() {
|
||||
if (typeof navigator == "undefined") return false
|
||||
let agent = navigator.userAgent
|
||||
return !/Edge\/\d/.test(agent) && /AppleWebKit/.test(agent) && /Mobile\/\w+/.test(agent)
|
||||
}
|
||||
|
||||
// :: (Object) → Plugin
|
||||
// A plugin that will place a menu bar above the editor. Note that
|
||||
// this involves wrapping the editor in an additional `<div>`.
|
||||
//
|
||||
// options::-
|
||||
// Supports the following options:
|
||||
//
|
||||
// content:: [[MenuElement]]
|
||||
// Provides the content of the menu, as a nested array to be
|
||||
// passed to `renderGrouped`.
|
||||
//
|
||||
// floating:: ?bool
|
||||
// Determines whether the menu floats, i.e. whether it sticks to
|
||||
// the top of the viewport when the editor is partially scrolled
|
||||
// out of view.
|
||||
export function menuBar(options) {
|
||||
return new Plugin({
|
||||
view(editorView) { return new MenuBarView(editorView, options) }
|
||||
})
|
||||
}
|
||||
|
||||
class MenuBarView {
|
||||
constructor(editorView, options) {
|
||||
this.editorView = editorView
|
||||
this.options = options
|
||||
|
||||
this.wrapper = crel("div", {class: prefix + "-wrapper"})
|
||||
this.menu = this.wrapper.appendChild(crel("div", {class: prefix}))
|
||||
this.menu.className = prefix
|
||||
this.spacer = null
|
||||
|
||||
if (editorView.dom.parentNode)
|
||||
editorView.dom.parentNode.replaceChild(this.wrapper, editorView.dom)
|
||||
this.wrapper.appendChild(editorView.dom)
|
||||
|
||||
this.maxHeight = 0
|
||||
this.widthForMaxHeight = 0
|
||||
this.floating = false
|
||||
|
||||
let {dom, update} = renderGrouped(this.editorView, this.options.content)
|
||||
this.contentUpdate = update
|
||||
this.menu.appendChild(dom)
|
||||
this.update()
|
||||
|
||||
if (options.floating && !isIOS()) {
|
||||
this.updateFloat()
|
||||
let potentialScrollers = getAllWrapping(this.wrapper)
|
||||
this.scrollFunc = (e) => {
|
||||
let root = this.editorView.root
|
||||
if (!(root.body || root).contains(this.wrapper)) {
|
||||
potentialScrollers.forEach(el => el.removeEventListener("scroll", this.scrollFunc))
|
||||
} else {
|
||||
this.updateFloat(e.target.getBoundingClientRect && e.target)
|
||||
}
|
||||
}
|
||||
potentialScrollers.forEach(el => el.addEventListener('scroll', this.scrollFunc))
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
this.contentUpdate(this.editorView.state)
|
||||
|
||||
if (this.floating) {
|
||||
this.updateScrollCursor()
|
||||
} else {
|
||||
if (this.menu.offsetWidth != this.widthForMaxHeight) {
|
||||
this.widthForMaxHeight = this.menu.offsetWidth
|
||||
this.maxHeight = 0
|
||||
}
|
||||
if (this.menu.offsetHeight > this.maxHeight) {
|
||||
this.maxHeight = this.menu.offsetHeight
|
||||
this.menu.style.minHeight = this.maxHeight + "px"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateScrollCursor() {
|
||||
let selection = this.editorView.root.getSelection()
|
||||
if (!selection.focusNode) return
|
||||
let rects = selection.getRangeAt(0).getClientRects()
|
||||
let selRect = rects[selectionIsInverted(selection) ? 0 : rects.length - 1]
|
||||
if (!selRect) return
|
||||
let menuRect = this.menu.getBoundingClientRect()
|
||||
if (selRect.top < menuRect.bottom && selRect.bottom > menuRect.top) {
|
||||
let scrollable = findWrappingScrollable(this.wrapper)
|
||||
if (scrollable) scrollable.scrollTop -= (menuRect.bottom - selRect.top)
|
||||
}
|
||||
}
|
||||
|
||||
updateFloat(scrollAncestor) {
|
||||
let parent = this.wrapper, editorRect = parent.getBoundingClientRect(),
|
||||
top = scrollAncestor ? Math.max(0, scrollAncestor.getBoundingClientRect().top) : 0
|
||||
|
||||
if (this.floating) {
|
||||
if (editorRect.top >= top || editorRect.bottom < this.menu.offsetHeight + 10) {
|
||||
this.floating = false
|
||||
this.menu.style.position = this.menu.style.left = this.menu.style.top = this.menu.style.width = ""
|
||||
this.menu.style.display = ""
|
||||
this.spacer.parentNode.removeChild(this.spacer)
|
||||
this.spacer = null
|
||||
} else {
|
||||
let border = (parent.offsetWidth - parent.clientWidth) / 2
|
||||
this.menu.style.left = (editorRect.left + border) + "px"
|
||||
this.menu.style.display = (editorRect.top > window.innerHeight ? "none" : "")
|
||||
if (scrollAncestor) this.menu.style.top = top + "px"
|
||||
}
|
||||
} else {
|
||||
if (editorRect.top < top && editorRect.bottom >= this.menu.offsetHeight + 10) {
|
||||
this.floating = true
|
||||
let menuRect = this.menu.getBoundingClientRect()
|
||||
this.menu.style.left = menuRect.left + "px"
|
||||
this.menu.style.width = menuRect.width + "px"
|
||||
if (scrollAncestor) this.menu.style.top = top + "px"
|
||||
this.menu.style.position = "fixed"
|
||||
this.spacer = crel("div", {class: prefix + "-spacer", style: `height: ${menuRect.height}px`})
|
||||
parent.insertBefore(this.spacer, this.menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.wrapper.parentNode)
|
||||
this.wrapper.parentNode.replaceChild(this.editorView.dom, this.wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
// Not precise, but close enough
|
||||
function selectionIsInverted(selection) {
|
||||
if (selection.anchorNode == selection.focusNode) return selection.anchorOffset > selection.focusOffset
|
||||
return selection.anchorNode.compareDocumentPosition(selection.focusNode) == Node.DOCUMENT_POSITION_FOLLOWING
|
||||
}
|
||||
|
||||
function findWrappingScrollable(node) {
|
||||
for (let cur = node.parentNode; cur; cur = cur.parentNode)
|
||||
if (cur.scrollHeight > cur.clientHeight) return cur
|
||||
}
|
||||
|
||||
function getAllWrapping(node) {
|
||||
let res = [window]
|
||||
for (let cur = node.parentNode; cur; cur = cur.parentNode)
|
||||
res.push(cur)
|
||||
return res
|
||||
}
|
||||
26
resources/js/editor/node-views/IframeView.js
Normal file
26
resources/js/editor/node-views/IframeView.js
Normal file
@@ -0,0 +1,26 @@
|
||||
class IframeView {
|
||||
/**
|
||||
* @param {PmNode} node
|
||||
* @param {PmView} view
|
||||
* @param {(function(): number)} getPos
|
||||
*/
|
||||
constructor(node, view, getPos) {
|
||||
this.dom = document.createElement('div');
|
||||
this.dom.classList.add('ProseMirror-iframewrap');
|
||||
|
||||
this.iframe = document.createElement("iframe");
|
||||
for (const [key, value] of Object.entries(node.attrs)) {
|
||||
if (value) {
|
||||
this.iframe.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
this.dom.appendChild(this.iframe);
|
||||
}
|
||||
|
||||
stopEvent() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default IframeView;
|
||||
197
resources/js/editor/node-views/ImageView.js
Normal file
197
resources/js/editor/node-views/ImageView.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import {positionHandlesAtCorners, removeHandles, renderHandlesAtCorners} from "./node-view-utils";
|
||||
import {NodeSelection} from "prosemirror-state";
|
||||
|
||||
class ImageView {
|
||||
/**
|
||||
* @param {PmNode} node
|
||||
* @param {PmView} view
|
||||
* @param {(function(): number)} getPos
|
||||
*/
|
||||
constructor(node, view, getPos) {
|
||||
this.dom = document.createElement('div');
|
||||
this.dom.classList.add('ProseMirror-imagewrap');
|
||||
|
||||
this.image = document.createElement("img");
|
||||
this.image.src = node.attrs.src;
|
||||
this.image.alt = node.attrs.alt;
|
||||
if (node.attrs.width) {
|
||||
this.image.width = node.attrs.width;
|
||||
}
|
||||
if (node.attrs.height) {
|
||||
this.image.height = node.attrs.height;
|
||||
}
|
||||
|
||||
this.dom.appendChild(this.image);
|
||||
|
||||
this.handles = [];
|
||||
this.handleDragStartInfo = null;
|
||||
this.handleDragMoveDimensions = null;
|
||||
this.removeHandlesListener = this.removeHandlesListener.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
this.handleMouseUp = this.handleMouseUp.bind(this);
|
||||
this.handleMouseDown = this.handleMouseDown.bind(this);
|
||||
|
||||
this.dom.addEventListener("click", event => {
|
||||
this.showHandles();
|
||||
});
|
||||
|
||||
// Show handles if selected
|
||||
if (view.state.selection.node === node) {
|
||||
window.setTimeout(() => {
|
||||
this.showHandles();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
this.updateImageDimensions = function (width, height) {
|
||||
const attrs = Object.assign({}, node.attrs, {width, height});
|
||||
let tr = view.state.tr;
|
||||
const position = getPos();
|
||||
tr = tr.setNodeMarkup(position, null, attrs)
|
||||
tr = tr.setSelection(NodeSelection.create(tr.doc, position));
|
||||
view.dispatch(tr);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
showHandles() {
|
||||
if (this.handles.length === 0) {
|
||||
this.image.dataset.showHandles = 'true';
|
||||
window.addEventListener('click', this.removeHandlesListener);
|
||||
this.handles = renderHandlesAtCorners(this.image);
|
||||
for (const handle of this.handles) {
|
||||
handle.addEventListener('mousedown', this.handleMouseDown);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeHandlesListener(event) {
|
||||
if (!this.dom.contains(event.target)) {
|
||||
this.removeHandles();
|
||||
this.handles = [];
|
||||
}
|
||||
}
|
||||
|
||||
removeHandles() {
|
||||
removeHandles(this.handles);
|
||||
window.removeEventListener('click', this.removeHandlesListener);
|
||||
delete this.image.dataset.showHandles;
|
||||
}
|
||||
|
||||
stopEvent() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
handleMouseDown(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const imageBounds = this.image.getBoundingClientRect();
|
||||
const handle = event.target;
|
||||
this.handleDragStartInfo = {
|
||||
x: event.screenX,
|
||||
y: event.screenY,
|
||||
ratio: imageBounds.width / imageBounds.height,
|
||||
bounds: imageBounds,
|
||||
handleX: handle.dataset.x,
|
||||
handleY: handle.dataset.y,
|
||||
};
|
||||
|
||||
this.createDragDummy(imageBounds);
|
||||
this.dom.appendChild(this.dragDummy);
|
||||
|
||||
window.addEventListener('mousemove', this.handleMouseMove);
|
||||
window.addEventListener('mouseup', this.handleMouseUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DOMRect} bounds
|
||||
*/
|
||||
createDragDummy(bounds) {
|
||||
this.dragDummy = this.image.cloneNode();
|
||||
this.dragDummy.style.opacity = '0.5';
|
||||
this.dragDummy.classList.add('ProseMirror-dragdummy');
|
||||
this.dragDummy.style.width = bounds.width + 'px';
|
||||
this.dragDummy.style.height = bounds.height + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
handleMouseUp(event) {
|
||||
if (this.handleDragMoveDimensions) {
|
||||
const {width, height} = this.handleDragMoveDimensions;
|
||||
this.updateImageDimensions(String(width), String(height));
|
||||
}
|
||||
|
||||
window.removeEventListener('mousemove', this.handleMouseMove);
|
||||
window.removeEventListener('mouseup', this.handleMouseUp);
|
||||
this.handleDragStartInfo = null;
|
||||
this.handleDragMoveDimensions = null;
|
||||
this.dragDummy.remove();
|
||||
positionHandlesAtCorners(this.image, this.handles);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
handleMouseMove(event) {
|
||||
const originalBounds = this.handleDragStartInfo.bounds;
|
||||
|
||||
// Calculate change in x & y, flip amounts depending on handle
|
||||
let xChange = event.screenX - this.handleDragStartInfo.x;
|
||||
if (this.handleDragStartInfo.handleX === 'left') {
|
||||
xChange = -xChange;
|
||||
}
|
||||
let yChange = event.screenY - this.handleDragStartInfo.y;
|
||||
if (this.handleDragStartInfo.handleY === 'top') {
|
||||
yChange = -yChange;
|
||||
}
|
||||
|
||||
// Prevent images going too small or into negative bounds
|
||||
if (originalBounds.width + xChange < 10) {
|
||||
xChange = -originalBounds.width + 10;
|
||||
}
|
||||
if (originalBounds.height + yChange < 10) {
|
||||
yChange = -originalBounds.height + 10;
|
||||
}
|
||||
|
||||
// Choose the larger dimension change and align the other to keep
|
||||
// image aspect ratio, aligning growth/reduction direction
|
||||
if (Math.abs(xChange) > Math.abs(yChange)) {
|
||||
yChange = Math.floor(xChange * this.handleDragStartInfo.ratio);
|
||||
if (yChange * xChange < 0) {
|
||||
yChange = -yChange;
|
||||
}
|
||||
} else {
|
||||
xChange = Math.floor(yChange / this.handleDragStartInfo.ratio);
|
||||
if (xChange * yChange < 0) {
|
||||
xChange = -xChange;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate our new sizes
|
||||
const newWidth = originalBounds.width + xChange;
|
||||
const newHeight = originalBounds.height + yChange;
|
||||
|
||||
// Apply the sizes and positioning to our ghost dummy
|
||||
this.dragDummy.style.width = `${newWidth}px`;
|
||||
if (this.handleDragStartInfo.handleX === 'left') {
|
||||
this.dragDummy.style.left = `${-xChange}px`;
|
||||
}
|
||||
this.dragDummy.style.height = `${newHeight}px`;
|
||||
if (this.handleDragStartInfo.handleY === 'top') {
|
||||
this.dragDummy.style.top = `${-yChange}px`;
|
||||
}
|
||||
|
||||
// Update corners and track dimension changes for later application
|
||||
positionHandlesAtCorners(this.dragDummy, this.handles);
|
||||
this.handleDragMoveDimensions = {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageView;
|
||||
21
resources/js/editor/node-views/TableView.js
Normal file
21
resources/js/editor/node-views/TableView.js
Normal file
@@ -0,0 +1,21 @@
|
||||
class TableView {
|
||||
/**
|
||||
* @param {PmNode} node
|
||||
* @param {PmView} view
|
||||
* @param {(function(): number)} getPos
|
||||
*/
|
||||
constructor(node, view, getPos) {
|
||||
this.dom = document.createElement("div")
|
||||
this.dom.className = "ProseMirror-tableWrapper"
|
||||
this.table = this.dom.appendChild(document.createElement("table"));
|
||||
this.table.setAttribute('style', node.attrs.style);
|
||||
this.colgroup = this.table.appendChild(document.createElement("colgroup"));
|
||||
this.contentDOM = this.table.appendChild(document.createElement("tbody"));
|
||||
}
|
||||
|
||||
ignoreMutation(record) {
|
||||
return record.type == "attributes" && (record.target == this.table || this.colgroup.contains(record.target))
|
||||
}
|
||||
}
|
||||
|
||||
export default TableView;
|
||||
11
resources/js/editor/node-views/index.js
Normal file
11
resources/js/editor/node-views/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import ImageView from "./ImageView";
|
||||
import IframeView from "./IframeView";
|
||||
import TableView from "./TableView";
|
||||
|
||||
const views = {
|
||||
image: (node, view, getPos) => new ImageView(node, view, getPos),
|
||||
iframe: (node, view, getPos) => new IframeView(node, view, getPos),
|
||||
table: (node, view, getPos) => new TableView(node, view, getPos),
|
||||
};
|
||||
|
||||
export default views;
|
||||
58
resources/js/editor/node-views/node-view-utils.js
Normal file
58
resources/js/editor/node-views/node-view-utils.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import crel from "crelt";
|
||||
|
||||
/**
|
||||
* Render grab handles at the corners of the given element.
|
||||
* @param {Element} elem
|
||||
* @return {Element[]}
|
||||
*/
|
||||
export function renderHandlesAtCorners(elem) {
|
||||
const handles = [];
|
||||
const baseClass = 'ProseMirror-grabhandle';
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const y = (i < 2) ? 'top' : 'bottom';
|
||||
const x = (i === 0 || i === 3) ? 'left' : 'right';
|
||||
const handle = crel('div', {
|
||||
class: `${baseClass} ${baseClass}-${x}-${y}`,
|
||||
});
|
||||
handle.dataset.y = y;
|
||||
handle.dataset.x = x;
|
||||
handles.push(handle);
|
||||
elem.parentNode.appendChild(handle);
|
||||
}
|
||||
|
||||
positionHandlesAtCorners(elem, handles);
|
||||
return handles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element[]} handles
|
||||
*/
|
||||
export function removeHandles(handles) {
|
||||
for (const handle of handles) {
|
||||
handle.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Element} element
|
||||
* @param {[Element, Element, Element, Element]}handles
|
||||
*/
|
||||
export function positionHandlesAtCorners(element, handles) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const parentBounds = element.parentElement.getBoundingClientRect();
|
||||
const positions = [
|
||||
{x: bounds.left - parentBounds.left, y: bounds.top - parentBounds.top},
|
||||
{x: bounds.right - parentBounds.left, y: bounds.top - parentBounds.top},
|
||||
{x: bounds.right - parentBounds.left, y: bounds.bottom - parentBounds.top},
|
||||
{x: bounds.left - parentBounds.left, y: bounds.bottom - parentBounds.top},
|
||||
];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const {x, y} = positions[i];
|
||||
const handle = handles[i];
|
||||
handle.style.left = (x - 6) + 'px';
|
||||
handle.style.top = (y - 6) + 'px';
|
||||
}
|
||||
}
|
||||
288
resources/js/editor/plugins/table-resizing.js
Normal file
288
resources/js/editor/plugins/table-resizing.js
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* This file originates from https://github.com/ProseMirror/prosemirror-tables
|
||||
* and is hence subject to the MIT license found here:
|
||||
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
|
||||
* @copyright Marijn Haverbeke and others
|
||||
*/
|
||||
|
||||
import {Plugin, PluginKey} from "prosemirror-state"
|
||||
import {Decoration, DecorationSet} from "prosemirror-view"
|
||||
import {
|
||||
cellAround,
|
||||
pointsAtCell,
|
||||
setAttr,
|
||||
TableMap,
|
||||
} from "prosemirror-tables";
|
||||
|
||||
export const key = new PluginKey("tableColumnResizing")
|
||||
|
||||
export function columnResizing(options = {}) {
|
||||
const {
|
||||
handleWidth, cellMinWidth, lastColumnResizable
|
||||
} = Object.assign({
|
||||
handleWidth: 5,
|
||||
cellMinWidth: 25,
|
||||
lastColumnResizable: true
|
||||
}, options);
|
||||
|
||||
let plugin = new Plugin({
|
||||
key,
|
||||
state: {
|
||||
init(_, state) {
|
||||
return new ResizeState(-1, false)
|
||||
},
|
||||
apply(tr, prev) {
|
||||
return prev.apply(tr)
|
||||
}
|
||||
},
|
||||
props: {
|
||||
attributes(state) {
|
||||
let pluginState = key.getState(state)
|
||||
return pluginState.activeHandle > -1 ? {class: "resize-cursor"} : null
|
||||
},
|
||||
|
||||
handleDOMEvents: {
|
||||
mousemove(view, event) {
|
||||
handleMouseMove(view, event, handleWidth, cellMinWidth, lastColumnResizable)
|
||||
},
|
||||
mouseleave(view) {
|
||||
handleMouseLeave(view)
|
||||
},
|
||||
mousedown(view, event) {
|
||||
handleMouseDown(view, event, cellMinWidth)
|
||||
}
|
||||
},
|
||||
|
||||
decorations(state) {
|
||||
let pluginState = key.getState(state)
|
||||
if (pluginState.activeHandle > -1) return handleDecorations(state, pluginState.activeHandle)
|
||||
},
|
||||
|
||||
nodeViews: {}
|
||||
}
|
||||
})
|
||||
return plugin
|
||||
}
|
||||
|
||||
class ResizeState {
|
||||
constructor(activeHandle, dragging) {
|
||||
this.activeHandle = activeHandle
|
||||
this.dragging = dragging
|
||||
}
|
||||
|
||||
apply(tr) {
|
||||
let state = this, action = tr.getMeta(key)
|
||||
if (action && action.setHandle != null)
|
||||
return new ResizeState(action.setHandle, null)
|
||||
if (action && action.setDragging !== undefined)
|
||||
return new ResizeState(state.activeHandle, action.setDragging)
|
||||
if (state.activeHandle > -1 && tr.docChanged) {
|
||||
let handle = tr.mapping.map(state.activeHandle, -1)
|
||||
if (!pointsAtCell(tr.doc.resolve(handle))) handle = null
|
||||
state = new ResizeState(handle, state.dragging)
|
||||
}
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(view, event, handleWidth, cellMinWidth, lastColumnResizable) {
|
||||
let pluginState = key.getState(view.state)
|
||||
|
||||
if (!pluginState.dragging) {
|
||||
let target = domCellAround(event.target), cell = -1
|
||||
if (target) {
|
||||
let {left, right} = target.getBoundingClientRect()
|
||||
if (event.clientX - left <= handleWidth)
|
||||
cell = edgeCell(view, event, "left")
|
||||
else if (right - event.clientX <= handleWidth)
|
||||
cell = edgeCell(view, event, "right")
|
||||
}
|
||||
|
||||
if (cell != pluginState.activeHandle) {
|
||||
if (!lastColumnResizable && cell !== -1) {
|
||||
let $cell = view.state.doc.resolve(cell)
|
||||
let table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1)
|
||||
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1
|
||||
|
||||
if (col == map.width - 1) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updateHandle(view, cell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave(view) {
|
||||
let pluginState = key.getState(view.state)
|
||||
if (pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1)
|
||||
}
|
||||
|
||||
function handleMouseDown(view, event, cellMinWidth) {
|
||||
let pluginState = key.getState(view.state)
|
||||
if (pluginState.activeHandle == -1 || pluginState.dragging) return false
|
||||
|
||||
let cell = view.state.doc.nodeAt(pluginState.activeHandle)
|
||||
let width = currentColWidth(view, pluginState.activeHandle, cell.attrs)
|
||||
view.dispatch(view.state.tr.setMeta(key, {setDragging: {startX: event.clientX, startWidth: width}}))
|
||||
|
||||
function finish(event) {
|
||||
window.removeEventListener("mouseup", finish)
|
||||
window.removeEventListener("mousemove", move)
|
||||
let pluginState = key.getState(view.state)
|
||||
if (pluginState.dragging) {
|
||||
updateColumnWidth(view, pluginState.activeHandle, draggedWidth(pluginState.dragging, event, cellMinWidth))
|
||||
view.dispatch(view.state.tr.setMeta(key, {setDragging: null}))
|
||||
}
|
||||
}
|
||||
|
||||
function move(event) {
|
||||
if (!event.which) return finish(event)
|
||||
let pluginState = key.getState(view.state)
|
||||
let dragged = draggedWidth(pluginState.dragging, event, cellMinWidth)
|
||||
displayColumnWidth(view, pluginState.activeHandle, dragged, cellMinWidth)
|
||||
}
|
||||
|
||||
window.addEventListener("mouseup", finish)
|
||||
window.addEventListener("mousemove", move)
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
function currentColWidth(view, cellPos, {colspan, colwidth}) {
|
||||
let width = colwidth && colwidth[colwidth.length - 1]
|
||||
if (width) return width
|
||||
let dom = view.domAtPos(cellPos)
|
||||
let node = dom.node.childNodes[dom.offset]
|
||||
let domWidth = node.offsetWidth, parts = colspan
|
||||
if (colwidth) for (let i = 0; i < colspan; i++) if (colwidth[i]) {
|
||||
domWidth -= colwidth[i]
|
||||
parts--
|
||||
}
|
||||
return domWidth / parts
|
||||
}
|
||||
|
||||
function domCellAround(target) {
|
||||
while (target && target.nodeName != "TD" && target.nodeName != "TH")
|
||||
target = target.classList.contains("ProseMirror") ? null : target.parentNode
|
||||
return target
|
||||
}
|
||||
|
||||
function edgeCell(view, event, side) {
|
||||
let found = view.posAtCoords({left: event.clientX, top: event.clientY})
|
||||
if (!found) return -1
|
||||
let {pos} = found
|
||||
let $cell = cellAround(view.state.doc.resolve(pos))
|
||||
if (!$cell) return -1
|
||||
if (side == "right") return $cell.pos
|
||||
let map = TableMap.get($cell.node(-1)), start = $cell.start(-1)
|
||||
let index = map.map.indexOf($cell.pos - start)
|
||||
return index % map.width == 0 ? -1 : start + map.map[index - 1]
|
||||
}
|
||||
|
||||
function draggedWidth(dragging, event, cellMinWidth) {
|
||||
let offset = event.clientX - dragging.startX
|
||||
return Math.max(cellMinWidth, dragging.startWidth + offset)
|
||||
}
|
||||
|
||||
function updateHandle(view, value) {
|
||||
view.dispatch(view.state.tr.setMeta(key, {setHandle: value}))
|
||||
}
|
||||
|
||||
function updateColumnWidth(view, cell, width) {
|
||||
let $cell = view.state.doc.resolve(cell);
|
||||
let table = $cell.node(-1);
|
||||
let map = TableMap.get(table);
|
||||
let start = $cell.start(-1);
|
||||
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
|
||||
let tr = view.state.tr;
|
||||
|
||||
for (let row = 0; row < map.height; row++) {
|
||||
let mapIndex = row * map.width + col;
|
||||
// Rowspanning cell that has already been handled
|
||||
if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) continue
|
||||
let pos = map.map[mapIndex]
|
||||
let {attrs} = table.nodeAt(pos);
|
||||
const newWidth = (attrs.colspan * width) + 'px';
|
||||
|
||||
tr.setNodeMarkup(start + pos, null, setAttr(attrs, "width", newWidth));
|
||||
}
|
||||
|
||||
if (tr.docChanged) view.dispatch(tr)
|
||||
}
|
||||
|
||||
function displayColumnWidth(view, cell, width, cellMinWidth) {
|
||||
const $cell = view.state.doc.resolve(cell)
|
||||
const table = $cell.node(-1);
|
||||
const start = $cell.start(-1);
|
||||
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1
|
||||
let dom = view.domAtPos($cell.start(-1)).node
|
||||
while (dom.nodeName !== "TABLE") {
|
||||
dom = dom.parentNode
|
||||
}
|
||||
updateColumnsOnResize(view, table, dom, cellMinWidth, col, width)
|
||||
}
|
||||
|
||||
|
||||
function updateColumnsOnResize(view, tableNode, tableDom, cellMinWidth, overrideCol, overrideValue) {
|
||||
console.log({tableNode, tableDom, cellMinWidth, overrideCol, overrideValue});
|
||||
let totalWidth = 0;
|
||||
let fixedWidth = true;
|
||||
const rows = tableDom.querySelectorAll('tr');
|
||||
|
||||
for (let y = 0; y < rows.length; y++) {
|
||||
const row = rows[y];
|
||||
const cell = row.children[overrideCol];
|
||||
cell.style.width = `${overrideValue}px`;
|
||||
if (y === 0) {
|
||||
for (let x = 0; x < row.children.length; x++) {
|
||||
const cell = row.children[x];
|
||||
if (cell.style.width) {
|
||||
const width = Number(cell.style.width.replace('px', ''));
|
||||
totalWidth += width || cellMinWidth;
|
||||
} else {
|
||||
fixedWidth = false;
|
||||
totalWidth += cellMinWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(totalWidth);
|
||||
if (fixedWidth) {
|
||||
tableDom.style.width = totalWidth + "px"
|
||||
tableDom.style.minWidth = ""
|
||||
} else {
|
||||
tableDom.style.width = ""
|
||||
tableDom.style.minWidth = totalWidth + "px"
|
||||
}
|
||||
}
|
||||
|
||||
function zeroes(n) {
|
||||
let result = []
|
||||
for (let i = 0; i < n; i++) result.push(0)
|
||||
return result
|
||||
}
|
||||
|
||||
function handleDecorations(state, cell) {
|
||||
let decorations = []
|
||||
let $cell = state.doc.resolve(cell)
|
||||
let table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1)
|
||||
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan
|
||||
for (let row = 0; row < map.height; row++) {
|
||||
let index = col + row * map.width - 1
|
||||
// For positions that are have either a different cell or the end
|
||||
// of the table to their right, and either the top of the table or
|
||||
// a different cell above them, add a decoration
|
||||
if ((col == map.width || map.map[index] != map.map[index + 1]) &&
|
||||
(row == 0 || map.map[index - 1] != map.map[index - 1 - map.width])) {
|
||||
let cellPos = map.map[index]
|
||||
let pos = start + cellPos + table.nodeAt(cellPos).nodeSize - 1
|
||||
let dom = document.createElement("div")
|
||||
dom.className = "column-resize-handle"
|
||||
decorations.push(Decoration.widget(pos, dom))
|
||||
}
|
||||
}
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
}
|
||||
131
resources/js/editor/schema-marks.js
Normal file
131
resources/js/editor/schema-marks.js
Normal file
@@ -0,0 +1,131 @@
|
||||
const link = {
|
||||
attrs: {
|
||||
href: {},
|
||||
title: {default: null},
|
||||
target: {default: null}
|
||||
},
|
||||
inclusive: false,
|
||||
parseDOM: [{
|
||||
tag: "a[href]", getAttrs: function getAttrs(dom) {
|
||||
return {
|
||||
href: dom.getAttribute("href"),
|
||||
title: dom.getAttribute("title"),
|
||||
target: dom.getAttribute("target"),
|
||||
}
|
||||
}
|
||||
}],
|
||||
toDOM: function toDOM(node) {
|
||||
const ref = node.attrs;
|
||||
const href = ref.href;
|
||||
const title = ref.title;
|
||||
const target = ref.target;
|
||||
return ["a", {href, title, target}, 0]
|
||||
}
|
||||
};
|
||||
|
||||
const em = {
|
||||
parseDOM: [{tag: "i"}, {tag: "em"}, {style: "font-style=italic"}],
|
||||
toDOM() {
|
||||
return ["em", 0]
|
||||
}
|
||||
};
|
||||
|
||||
const strong = {
|
||||
parseDOM: [{tag: "strong"},
|
||||
// This works around a Google Docs misbehavior where
|
||||
// pasted content will be inexplicably wrapped in `<b>`
|
||||
// tags with a font-weight normal.
|
||||
{
|
||||
tag: "b", getAttrs: function (node) {
|
||||
return node.style.fontWeight != "normal" && null;
|
||||
}
|
||||
},
|
||||
{
|
||||
style: "font-weight", getAttrs: function (value) {
|
||||
return /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null;
|
||||
}
|
||||
}],
|
||||
toDOM() {
|
||||
return ["strong", 0]
|
||||
}
|
||||
};
|
||||
|
||||
const code = {
|
||||
parseDOM: [{tag: "code"}],
|
||||
toDOM() {
|
||||
return ["code", 0]
|
||||
}
|
||||
};
|
||||
|
||||
const underline = {
|
||||
parseDOM: [{tag: "u"}, {style: "text-decoration=underline"}],
|
||||
toDOM() {
|
||||
return ["span", {style: "text-decoration: underline;"}, 0];
|
||||
}
|
||||
};
|
||||
|
||||
const strike = {
|
||||
parseDOM: [{tag: "s"}, {tag: "strike"}, {style: "text-decoration=line-through"}],
|
||||
toDOM() {
|
||||
return ["span", {style: "text-decoration: line-through;"}, 0];
|
||||
}
|
||||
};
|
||||
|
||||
const superscript = {
|
||||
parseDOM: [{tag: "sup"}],
|
||||
toDOM() {
|
||||
return ["sup", 0];
|
||||
}
|
||||
};
|
||||
|
||||
const subscript = {
|
||||
parseDOM: [{tag: "sub"}],
|
||||
toDOM() {
|
||||
return ["sub", 0];
|
||||
}
|
||||
};
|
||||
|
||||
const text_color = {
|
||||
attrs: {
|
||||
color: {},
|
||||
},
|
||||
parseDOM: [{
|
||||
style: 'color',
|
||||
getAttrs(color) {
|
||||
return {color}
|
||||
}
|
||||
}],
|
||||
toDOM(node) {
|
||||
return ['span', {style: `color: ${node.attrs.color};`}, 0];
|
||||
}
|
||||
};
|
||||
|
||||
const background_color = {
|
||||
attrs: {
|
||||
color: {},
|
||||
},
|
||||
parseDOM: [{
|
||||
style: 'background-color',
|
||||
getAttrs(color) {
|
||||
return {color}
|
||||
}
|
||||
}],
|
||||
toDOM(node) {
|
||||
return ['span', {style: `background-color: ${node.attrs.color};`}, 0];
|
||||
}
|
||||
};
|
||||
|
||||
const marks = {
|
||||
link,
|
||||
em,
|
||||
strong,
|
||||
code,
|
||||
underline,
|
||||
strike,
|
||||
superscript,
|
||||
subscript,
|
||||
text_color,
|
||||
background_color,
|
||||
};
|
||||
|
||||
export default marks;
|
||||
383
resources/js/editor/schema-nodes.js
Normal file
383
resources/js/editor/schema-nodes.js
Normal file
@@ -0,0 +1,383 @@
|
||||
import {orderedList, bulletList, listItem} from "prosemirror-schema-list";
|
||||
import {Fragment} from "prosemirror-model";
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} node
|
||||
* @return {string|null}
|
||||
*/
|
||||
function getAlignAttrFromDomNode(node) {
|
||||
const classList = node.classList;
|
||||
const styles = node.style || {};
|
||||
const alignments = ['right', 'left', 'center', 'justify'];
|
||||
for (const alignment of alignments) {
|
||||
if (classList.contains('align-' + alignment) || styles.textAlign === alignment) {
|
||||
return alignment;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param node
|
||||
* @param {Object} attrs
|
||||
* @return {Object}
|
||||
*/
|
||||
function addAlignmentAttr(node, attrs) {
|
||||
const positions = ['right', 'left', 'center', 'justify'];
|
||||
for (const position of positions) {
|
||||
if (node.attrs.align === position) {
|
||||
return addClassToAttrs('align-' + position, attrs);
|
||||
}
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function getAttrsParserForAlignment(node) {
|
||||
return {
|
||||
align: getAlignAttrFromDomNode(node),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} className
|
||||
* @param {Object} attrs
|
||||
* @return {Object}
|
||||
*/
|
||||
function addClassToAttrs(className, attrs) {
|
||||
return Object.assign({}, attrs, {
|
||||
class: attrs.class ? attrs.class + ' ' + className : className,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String[]} attrNames
|
||||
* @return {function(Element): {}}
|
||||
*/
|
||||
function domAttrsToAttrsParser(attrNames) {
|
||||
return function (node) {
|
||||
const attrs = {};
|
||||
for (const attr of attrNames) {
|
||||
attrs[attr] = node.hasAttribute(attr) ? node.getAttribute(attr) : null;
|
||||
}
|
||||
return attrs;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmNode} node
|
||||
* @param {String[]} attrNames
|
||||
*/
|
||||
function extractAttrsForDom(node, attrNames) {
|
||||
const domAttrs = {};
|
||||
for (const attr of attrNames) {
|
||||
if (node.attrs[attr]) {
|
||||
domAttrs[attr] = node.attrs[attr];
|
||||
}
|
||||
}
|
||||
return domAttrs;
|
||||
}
|
||||
|
||||
const doc = {
|
||||
content: "block+",
|
||||
};
|
||||
|
||||
const paragraph = {
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "p",
|
||||
getAttrs: getAttrsParserForAlignment,
|
||||
}
|
||||
],
|
||||
attrs: {
|
||||
align: {
|
||||
default: null,
|
||||
}
|
||||
},
|
||||
toDOM(node) {
|
||||
return ["p", addAlignmentAttr(node, {}), 0];
|
||||
}
|
||||
};
|
||||
|
||||
const blockquote = {
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{tag: "blockquote", getAttrs: getAttrsParserForAlignment}],
|
||||
attrs: {
|
||||
align: {
|
||||
default: null,
|
||||
}
|
||||
},
|
||||
toDOM(node) {
|
||||
return ["blockquote", addAlignmentAttr(node, {}), 0];
|
||||
}
|
||||
};
|
||||
|
||||
const horizontal_rule = {
|
||||
group: "block",
|
||||
parseDOM: [{tag: "hr"}],
|
||||
toDOM() {
|
||||
return ["hr"];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const headingParseGetAttrs = (level) => {
|
||||
return function (node) {
|
||||
return {level, align: getAlignAttrFromDomNode(node)};
|
||||
};
|
||||
};
|
||||
const heading = {
|
||||
attrs: {level: {default: 1}, align: {default: null}},
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [
|
||||
{tag: "h1", getAttrs: headingParseGetAttrs(1)},
|
||||
{tag: "h2", getAttrs: headingParseGetAttrs(2)},
|
||||
{tag: "h3", getAttrs: headingParseGetAttrs(3)},
|
||||
{tag: "h4", getAttrs: headingParseGetAttrs(4)},
|
||||
{tag: "h5", getAttrs: headingParseGetAttrs(5)},
|
||||
{tag: "h6", getAttrs: headingParseGetAttrs(6)},
|
||||
],
|
||||
toDOM(node) {
|
||||
return ["h" + node.attrs.level, addAlignmentAttr(node, {}), 0]
|
||||
}
|
||||
};
|
||||
|
||||
const code_block = {
|
||||
content: "text*",
|
||||
marks: "",
|
||||
group: "block",
|
||||
code: true,
|
||||
defining: true,
|
||||
parseDOM: [{tag: "pre", preserveWhitespace: "full"}],
|
||||
toDOM() {
|
||||
return ["pre", ["code", 0]];
|
||||
}
|
||||
};
|
||||
|
||||
const text = {
|
||||
group: "inline"
|
||||
};
|
||||
|
||||
const image = {
|
||||
inline: true,
|
||||
attrs: {
|
||||
src: {},
|
||||
alt: {default: null},
|
||||
title: {default: null},
|
||||
height: {default: null},
|
||||
width: {default: null},
|
||||
},
|
||||
group: "inline",
|
||||
draggable: true,
|
||||
parseDOM: [{
|
||||
tag: "img[src]", getAttrs: function getAttrs(dom) {
|
||||
return {
|
||||
src: dom.getAttribute("src"),
|
||||
title: dom.getAttribute("title"),
|
||||
alt: dom.getAttribute("alt"),
|
||||
height: dom.getAttribute("height"),
|
||||
width: dom.getAttribute("width"),
|
||||
}
|
||||
}
|
||||
}],
|
||||
toDOM: function toDOM(node) {
|
||||
const ref = node.attrs;
|
||||
const src = ref.src;
|
||||
const alt = ref.alt;
|
||||
const title = ref.title;
|
||||
const width = ref.width;
|
||||
const height = ref.height;
|
||||
return ["img", {src, alt, title, width, height}]
|
||||
}
|
||||
};
|
||||
|
||||
const iframe = {
|
||||
attrs: {
|
||||
src: {},
|
||||
height: {default: null},
|
||||
width: {default: null},
|
||||
title: {default: null},
|
||||
allow: {default: null},
|
||||
sandbox: {default: null},
|
||||
},
|
||||
group: "block",
|
||||
draggable: true,
|
||||
parseDOM: [{
|
||||
tag: "iframe",
|
||||
getAttrs: domAttrsToAttrsParser(["src", "width", "height", "title", "allow", "sandbox"]),
|
||||
}],
|
||||
toDOM(node) {
|
||||
const attrs = extractAttrsForDom(node, ["src", "width", "height", "title", "allow", "sandbox"])
|
||||
return ["iframe", attrs];
|
||||
}
|
||||
};
|
||||
|
||||
const hard_break = {
|
||||
inline: true,
|
||||
group: "inline",
|
||||
selectable: false,
|
||||
parseDOM: [{tag: "br"}],
|
||||
toDOM() {
|
||||
return ["br"];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const calloutParseGetAttrs = (type) => {
|
||||
return function (node) {
|
||||
return {type, align: getAlignAttrFromDomNode(node)};
|
||||
};
|
||||
};
|
||||
const callout = {
|
||||
attrs: {
|
||||
type: {default: 'info'},
|
||||
align: {default: null},
|
||||
},
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [
|
||||
{tag: 'p.callout.info', getAttrs: calloutParseGetAttrs('info'), priority: 75},
|
||||
{tag: 'p.callout.success', getAttrs: calloutParseGetAttrs('success'), priority: 75},
|
||||
{tag: 'p.callout.danger', getAttrs: calloutParseGetAttrs('danger'), priority: 75},
|
||||
{tag: 'p.callout.warning', getAttrs: calloutParseGetAttrs('warning'), priority: 75},
|
||||
{tag: 'p.callout', getAttrs: calloutParseGetAttrs('info'), priority: 75},
|
||||
],
|
||||
toDOM(node) {
|
||||
const type = node.attrs.type || 'info';
|
||||
return ['p', addAlignmentAttr(node, {class: 'callout ' + type}), 0];
|
||||
}
|
||||
};
|
||||
|
||||
const ordered_list = Object.assign({}, orderedList, {content: "list_item+", group: "block"});
|
||||
const bullet_list = Object.assign({}, bulletList, {content: "list_item+", group: "block"});
|
||||
const list_item = Object.assign({}, listItem, {content: 'paragraph block*'});
|
||||
|
||||
const table = {
|
||||
content: "table_row+",
|
||||
attrs: {
|
||||
style: {default: null},
|
||||
},
|
||||
tableRole: "table",
|
||||
isolating: true,
|
||||
group: "block",
|
||||
parseDOM: [{tag: "table", getAttrs: domAttrsToAttrsParser(['style'])}],
|
||||
toDOM(node) {
|
||||
return ["table", extractAttrsForDom(node, ['style']), ["tbody", 0]]
|
||||
}
|
||||
};
|
||||
|
||||
const table_row = {
|
||||
content: "(table_cell | table_header)*",
|
||||
tableRole: "row",
|
||||
parseDOM: [{tag: "tr"}],
|
||||
toDOM() { return ["tr", 0] }
|
||||
};
|
||||
|
||||
let cellAttrs = {
|
||||
colspan: {default: 1},
|
||||
rowspan: {default: 1},
|
||||
width: {default: null},
|
||||
height: {default: null},
|
||||
};
|
||||
|
||||
function getCellAttrs(dom) {
|
||||
return {
|
||||
colspan: Number(dom.getAttribute("colspan") || 1),
|
||||
rowspan: Number(dom.getAttribute("rowspan") || 1),
|
||||
width: dom.style.width || null,
|
||||
height: dom.style.height || null,
|
||||
};
|
||||
}
|
||||
|
||||
function setCellAttrs(node) {
|
||||
let attrs = {};
|
||||
|
||||
const styles = [];
|
||||
if (node.attrs.colspan != 1) attrs.colspan = node.attrs.colspan;
|
||||
if (node.attrs.rowspan != 1) attrs.rowspan = node.attrs.rowspan;
|
||||
if (node.attrs.width) styles.push(`width: ${node.attrs.width}`);
|
||||
if (node.attrs.height) styles.push(`height: ${node.attrs.height}`);
|
||||
if (styles) {
|
||||
attrs.style = styles.join(';');
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
const table_cell = {
|
||||
content: "block+",
|
||||
attrs: cellAttrs,
|
||||
tableRole: "cell",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "td", getAttrs: dom => getCellAttrs(dom)}],
|
||||
toDOM(node) { return ["td", setCellAttrs(node), 0] }
|
||||
};
|
||||
|
||||
const table_header = {
|
||||
content: "block+",
|
||||
attrs: cellAttrs,
|
||||
tableRole: "header_cell",
|
||||
isolating: true,
|
||||
parseDOM: [{tag: "th", getAttrs: dom => getCellAttrs(dom)}],
|
||||
toDOM(node) { return ["th", setCellAttrs(node), 0] }
|
||||
};
|
||||
|
||||
|
||||
const details = {
|
||||
content: "details_summary block*",
|
||||
isolating: true,
|
||||
group: "block",
|
||||
parseDOM: [{
|
||||
tag: "details",
|
||||
getAttrs(domNode) {
|
||||
return {}
|
||||
},
|
||||
}],
|
||||
toDOM(node) {
|
||||
return ["details", 0];
|
||||
}
|
||||
};
|
||||
|
||||
const details_summary = {
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
parseDOM: [{
|
||||
tag: "details summary",
|
||||
}],
|
||||
toDOM(node) {
|
||||
return ["summary", 0];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const nodes = {
|
||||
doc,
|
||||
paragraph,
|
||||
blockquote,
|
||||
horizontal_rule,
|
||||
heading,
|
||||
code_block,
|
||||
text,
|
||||
image,
|
||||
iframe,
|
||||
hard_break,
|
||||
callout,
|
||||
ordered_list,
|
||||
bullet_list,
|
||||
list_item,
|
||||
table,
|
||||
table_row,
|
||||
table_cell,
|
||||
table_header,
|
||||
details,
|
||||
details_summary,
|
||||
};
|
||||
|
||||
export default nodes;
|
||||
12
resources/js/editor/schema.js
Normal file
12
resources/js/editor/schema.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import {Schema} from "prosemirror-model";
|
||||
|
||||
import nodes from "./schema-nodes";
|
||||
import marks from "./schema-marks";
|
||||
|
||||
/** @var {PmSchema} schema */
|
||||
const schema = new Schema({
|
||||
nodes,
|
||||
marks,
|
||||
});
|
||||
|
||||
export default schema;
|
||||
106
resources/js/editor/types.js
Normal file
106
resources/js/editor/types.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @typedef {Object} PmEditorState
|
||||
* @property {PmNode} doc
|
||||
* @property {PmSelection} selection
|
||||
* @property {PmMark[]|null} storedMarks
|
||||
* @property {PmSchema} schema
|
||||
* @property {PmTransaction} tr
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmNode
|
||||
* @property {PmNodeType} type
|
||||
* @property {Object} attrs
|
||||
* @property {PmFragment} content
|
||||
* @property {PmMark[]} marks
|
||||
* @property {String|null} text
|
||||
* @property {Number} nodeSize
|
||||
* @property {Number} childCount
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmNodeType
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmMark
|
||||
* @property {PmMarkType} type
|
||||
* @property {Object} attrs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmMarkType
|
||||
* @property {String} name
|
||||
* @property {PmSchema} schema
|
||||
* @property {PmMarkSpec} spec
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmMarkSpec
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmSchema
|
||||
* @property {PmSchema} schema
|
||||
* @property {Object<PmNodeType>} nodes
|
||||
* @property {Object<PmMarkType>} marks
|
||||
* @property {PmNodeType} topNodeType
|
||||
* @property {Object} cached
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmSelection
|
||||
* @property {PmSelectionRange[]} ranges
|
||||
* @property {PmResolvedPos} $anchor
|
||||
* @property {PmResolvedPos} $head
|
||||
* @property {Number} anchor
|
||||
* @property {Number} head
|
||||
* @property {Number} from
|
||||
* @property {Number} to
|
||||
* @property {PmResolvedPos} $from
|
||||
* @property {PmResolvedPos} $to
|
||||
* @property {Boolean} empty
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmResolvedPos
|
||||
* @property {Number} pos
|
||||
* @property {Number} depth
|
||||
* @property {Number} parentOffset
|
||||
* @property {PmNode} parent
|
||||
* @property {PmNode} doc
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmSelectionRange
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmTransaction
|
||||
* @property {Number} time
|
||||
* @property {PmMark[]|null} storedMarks
|
||||
* @property {PmSelection} selection
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmFragment
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Function} PmCommandHandler
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmDispatchFunction} dispatch
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Function} PmDispatchFunction
|
||||
* @param {PmTransaction} tr
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PmView
|
||||
* @param {PmEditorState} state
|
||||
* @param {Element} dom
|
||||
* @param {Boolean} editable
|
||||
* @param {Boolean} composing
|
||||
*/
|
||||
131
resources/js/editor/util.js
Normal file
131
resources/js/editor/util.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import schema from "./schema";
|
||||
import {DOMParser, DOMSerializer} from "prosemirror-model";
|
||||
|
||||
/**
|
||||
* @param {String} html
|
||||
* @return {PmNode}
|
||||
*/
|
||||
export function htmlToDoc(html) {
|
||||
const renderDoc = document.implementation.createHTMLDocument();
|
||||
renderDoc.body.innerHTML = html;
|
||||
return DOMParser.fromSchema(schema).parse(renderDoc.body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmNode} doc
|
||||
* @return {string}
|
||||
*/
|
||||
export function docToHtml(doc) {
|
||||
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(doc.content);
|
||||
const renderDoc = document.implementation.createHTMLDocument();
|
||||
renderDoc.body.appendChild(fragment);
|
||||
return renderDoc.body.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @return {String}
|
||||
*/
|
||||
export function stateToHtml(state) {
|
||||
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(state.doc.content);
|
||||
const renderDoc = document.implementation.createHTMLDocument();
|
||||
renderDoc.body.appendChild(fragment);
|
||||
return renderDoc.body.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} object
|
||||
* @return {{}}
|
||||
*/
|
||||
export function nullifyEmptyValues(object) {
|
||||
const clean = {};
|
||||
for (const [key, value] of Object.entries(object)) {
|
||||
clean[key] = (value === "") ? null : value;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmSelection} selection
|
||||
* @param {PmMarkType} markType
|
||||
* @return {{from: Number, to: Number}}
|
||||
*/
|
||||
export function expandSelectionToMark(state, selection, markType) {
|
||||
let {from, to} = selection;
|
||||
const noRange = (from === to);
|
||||
if (noRange) {
|
||||
const markRange = markRangeAtPosition(state, markType, from);
|
||||
if (markRange.from !== -1) {
|
||||
from = markRange.from;
|
||||
to = markRange.to;
|
||||
}
|
||||
}
|
||||
return {from, to};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PmEditorState} state
|
||||
* @param {PmMarkType} markType
|
||||
* @param {Number} pos
|
||||
* @return {{from: Number, to: Number}}
|
||||
*/
|
||||
export function markRangeAtPosition(state, markType, pos) {
|
||||
const $pos = state.doc.resolve(pos);
|
||||
|
||||
const {parent, parentOffset} = $pos;
|
||||
const start = parent.childAfter(parentOffset);
|
||||
if (!start.node) return {from: -1, to: -1};
|
||||
|
||||
const mark = start.node.marks.find((mark) => mark.type === markType);
|
||||
if (!mark) return {from: -1, to: -1};
|
||||
|
||||
let startIndex = $pos.index();
|
||||
let startPos = $pos.start() + start.offset;
|
||||
let endIndex = startIndex + 1;
|
||||
let endPos = startPos + start.node.nodeSize;
|
||||
while (startIndex > 0 && mark.isInSet(parent.child(startIndex - 1).marks)) {
|
||||
startIndex -= 1;
|
||||
startPos -= parent.child(startIndex).nodeSize;
|
||||
}
|
||||
while (endIndex < parent.childCount && mark.isInSet(parent.child(endIndex).marks)) {
|
||||
endPos += parent.child(endIndex).nodeSize;
|
||||
endIndex += 1;
|
||||
}
|
||||
return {from: startPos, to: endPos};
|
||||
}
|
||||
|
||||
/**
|
||||
* @class KeyedMultiStack
|
||||
* Holds many stacks, seperated via a key, with a simple
|
||||
* interface to pop and push values to the stacks.
|
||||
*/
|
||||
export class KeyedMultiStack {
|
||||
|
||||
constructor() {
|
||||
this.stack = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} key
|
||||
* @return {undefined|*}
|
||||
*/
|
||||
pop(key) {
|
||||
if (Array.isArray(this.stack[key])) {
|
||||
return this.stack[key].pop();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} key
|
||||
* @param {*} value
|
||||
*/
|
||||
push(key, value) {
|
||||
if (this.stack[key] === undefined) {
|
||||
this.stack[key] = [];
|
||||
}
|
||||
|
||||
this.stack[key].push(value);
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'عرض القائمة',
|
||||
|
||||
@@ -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' => 'يجب أن تكون السمة أكبر من: القيمة.',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expand Header Menu',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Otvori meni u zaglavlju',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expand Header Menu',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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í',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Udvid header menu',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Aktiv',
|
||||
'status_inactive' => 'Inaktiv',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Header-Menü erweitern',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Aktiv',
|
||||
'status_inactive' => 'Inaktiv',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Header-Menü erweitern',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Active',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Never',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expand Header Menu',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
return [
|
||||
|
||||
// Pages
|
||||
'page_create' => 'creó la página',
|
||||
'page_create' => 'página creada',
|
||||
'page_create_notification' => 'Página creada correctamente',
|
||||
'page_update' => 'página actualizada',
|
||||
'page_update_notification' => 'Página actualizada correctamente',
|
||||
|
||||
@@ -73,9 +73,8 @@ return [
|
||||
'breadcrumb' => 'Miga de Pan',
|
||||
'status' => 'Estado',
|
||||
'status_active' => 'Activo',
|
||||
'status_inactive' => 'Inactivo',
|
||||
'status_inactive' => 'Inactive',
|
||||
'never' => 'Nunca',
|
||||
'none' => 'Ninguno',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Expandir el Menú de Cabecera',
|
||||
|
||||
@@ -341,7 +341,7 @@ return [
|
||||
'copy_consider' => 'Por favor, tenga en cuenta lo siguiente al copiar el contenido.',
|
||||
'copy_consider_permissions' => 'Los ajustes de permisos personalizados no serán copiados.',
|
||||
'copy_consider_owner' => 'Usted se convertirá en el dueño de todo el contenido copiado.',
|
||||
'copy_consider_images' => 'Los archivos de imagen de la página no serán duplicados y las imágenes originales conservarán su relación con la página a la que fueron subidos originalmente.',
|
||||
'copy_consider_images' => 'Los archivos de imagen de de las páginas no serán duplicados y las imágenes originales conservarán su relación con la página a la que fueron subidos originalmente.',
|
||||
'copy_consider_attachments' => 'Los archivos adjuntos de la página no serán copiados.',
|
||||
'copy_consider_access' => 'Un cambio de ubicación, propietario o permisos puede resultar en que este contenido sea accesible para aquellos que anteriormente no tuvieran acceso.',
|
||||
];
|
||||
|
||||
@@ -236,26 +236,26 @@ return [
|
||||
|
||||
// Webhooks
|
||||
'webhooks' => 'Webhooks',
|
||||
'webhooks_create' => 'Crear nuevo Webhook',
|
||||
'webhooks_create' => 'Crear Webhook',
|
||||
'webhooks_none_created' => 'No hay webhooks creados.',
|
||||
'webhooks_edit' => 'Editar Webhook',
|
||||
'webhooks_save' => 'Guardar Webhook',
|
||||
'webhooks_details' => 'Detalles del Webhook',
|
||||
'webhooks_details_desc' => 'Proporcione un nombre y un punto final de POST como destino para enviar los datos del webhook.',
|
||||
'webhooks_details_desc' => 'Proporcione un nombre y un punto final POST como destino para los datos del webhook que se enviarán.',
|
||||
'webhooks_events' => 'Eventos del Webhook',
|
||||
'webhooks_events_desc' => 'Seleccione todos los eventos que deberían activar este webhook.',
|
||||
'webhooks_events_warning' => 'Tenga en cuenta que estos eventos se activarán para todos los eventos seleccionados, incluso si se aplican permisos personalizados. Asegúrese de que el uso de este webhook no exponga contenido confidencial.',
|
||||
'webhooks_events_all' => 'Todos los eventos del sistema',
|
||||
'webhooks_name' => 'Nombre del Webhook',
|
||||
'webhooks_timeout' => 'Tiempo de Espera de Solicitud del Webhook (Segundos)',
|
||||
'webhooks_timeout' => 'Tiempo de Espera de Webhook (Segundos)',
|
||||
'webhooks_endpoint' => 'Punto final del Webhook',
|
||||
'webhooks_active' => 'Webhook Activo',
|
||||
'webhook_events_table_header' => 'Eventos',
|
||||
'webhooks_delete' => 'Eliminar Webhook',
|
||||
'webhooks_delete_warning' => 'Esto eliminará completamente del sistema este webhook con el nombre \':webhookName\'.',
|
||||
'webhooks_delete_confirm' => '¿Está seguro que quiere eliminar este webhook?',
|
||||
'webhooks_delete_warning' => 'Esto eliminará completamente este webhook, con el nombre \':webhookName\', del sistema.',
|
||||
'webhooks_delete_confirm' => '¿Seguro que quieres eliminar este webhook?',
|
||||
'webhooks_format_example' => 'Ejemplo de Formato de Webhook',
|
||||
'webhooks_format_example_desc' => 'Los datos del Webhook, en formato JSON, se envían como una solicitud POST al punto final siguiendo el formato mostrado a continuación. Las propiedades "related_item" y "url" son opcionales y dependerán del tipo de evento activado.',
|
||||
'webhooks_format_example_desc' => 'Los datos del Webhook se envían como una solicitud POST al punto final configurado como JSON siguiendo el formato mostrado a continuación. Las propiedades "related_item" y "url" son opcionales y dependerán del tipo de evento activado.',
|
||||
'webhooks_status' => 'Estado del Webhook',
|
||||
'webhooks_last_called' => 'Última Ejecución:',
|
||||
'webhooks_last_errored' => 'Último error:',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -28,8 +28,8 @@ return [
|
||||
'create_account' => 'Loo konto',
|
||||
'already_have_account' => 'Kasutajakonto juba olemas?',
|
||||
'dont_have_account' => 'Sul ei ole veel kontot?',
|
||||
'social_login' => 'Sisene läbi sotsiaalmeedia',
|
||||
'social_registration' => 'Registreeru läbi sotsiaalmeedia',
|
||||
'social_login' => 'Social Login',
|
||||
'social_registration' => 'Social Registration',
|
||||
'social_registration_text' => 'Registreeru ja logi sisse välise teenuse kaudu.',
|
||||
|
||||
'register_thanks' => 'Aitäh, et registreerusid!',
|
||||
|
||||
@@ -75,7 +75,6 @@ return [
|
||||
'status_active' => 'Aktiivne',
|
||||
'status_inactive' => 'Mitteaktiivne',
|
||||
'never' => 'Mitte kunagi',
|
||||
'none' => 'None',
|
||||
|
||||
// Header
|
||||
'header_menu_expand' => 'Laienda päisemenüü',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user