Compare commits

...

119 Commits

Author SHA1 Message Date
Dan Brown
8b10e991ac
Merge 97a46bc322 into f583354748 2024-09-30 11:16:10 +00:00
Dan Brown
97a46bc322 New translations notifications.php (Turkish) 2024-09-30 12:16:07 +01:00
Dan Brown
2ba311cb41 New translations activities.php (Turkish) 2024-09-30 12:16:06 +01:00
Dan Brown
f583354748
Maintenance: Removed stray dd from last commit 2024-09-29 16:50:48 +01:00
Dan Brown
d12e8ec923
Users: Improved user response for failed invite sending
Added specific handling to show relevant error message when user
creation fails due to invite sending errors, while also returning user
to the form with previous input.
Includes test to cover.

For #5195
2024-09-29 16:41:18 +01:00
Dan Brown
89f84c9a95
Pages: Updated editor field to always be set
- Migration for setting on existing pages
- Added test to cover simple new page scenario

For #5117
2024-09-29 14:36:41 +01:00
Dan Brown
6103a22feb
Exports: Made pdf command timeout configurable
Added test to cover.
For #5119
2024-09-27 16:33:58 +01:00
Dan Brown
42264f402d
CSS: Fixed floating search icon on mobile
Also updated styles to use logical elements instead of conditional rules
for altered search boxes.
Related to #2504
2024-09-27 16:02:13 +01:00
Dan Brown
abda9bc00a
PHP Dependancies: Updated packages pending major version changes
Closes #5222
2024-09-27 14:21:12 +01:00
Dan Brown
eec639d84e
Maintenance: Fixed js lint and SCSS build warnings 2024-09-27 13:57:39 +01:00
Dan Brown
56b9107c6b
Dependancies: Updated php & JS deps, updated license lists
Fixed issue now picked up by newer TS version
2024-09-27 12:29:19 +01:00
Dan Brown
b35b62d59f
Merge branch 'lexical' into development 2024-09-27 12:04:01 +01:00
Dan Brown
1b9310e766
Meta: Added lexical licensing info and added TS/JS CI testing 2024-09-27 10:45:48 +01:00
Dan Brown
a62d8381be
Lexical: Updated toolbar & text node exporting
- Updated toolbar to match existing editor, including dynamic RTL/LTR
  controls.
- Updated text node handling to not include spans and extra classes when
  not needed. Added & update tests to cover.
2024-09-23 17:36:16 +01:00
Dan Brown
8b32e6c15a
Page Editors: Added switching/options for new lexical editor 2024-09-22 20:06:55 +01:00
Dan Brown
c8ccb2bac7
Lexical: Range of fixes
- Prevented ui shortcuts running in editor
- Added form modal closing on submit
- Fixed ability to escape lists via enter on empty last item
2024-09-22 16:15:02 +01:00
Dan Brown
ef3de1050f
Lexical: Added UI translation support 2024-09-22 12:29:06 +01:00
Dan Brown
2add15bd72
Lexical: Added direction support to extra blocks
Also removed duplicated dir functionality that remained in core.
2024-09-22 12:07:24 +01:00
Dan Brown
e6edd9340e
Lexical: Added alignment detoggle, fixed inital focus area 2024-09-21 17:02:54 +01:00
Dan Brown
654a7a5d03
Lexical: Removed reconciler level direction handling
- Updated tests to consider changes
2024-09-21 13:00:16 +01:00
Dan Brown
dba8ab947f
Lexical: Finished conversion/update of test files 2024-09-20 15:31:19 +01:00
Dan Brown
787e06e3d8
Lexical: Adapted a range of further existing tests 2024-09-20 13:05:29 +01:00
Dan Brown
ccd486f2a9
Lexical: Got a range of Editor tests working 2024-09-18 17:31:51 +01:00
Dan Brown
22d078b47f
Lexical: Imported core lexical libs
Imported at 0.17.1, Modified to work in-app.
Added & configured test dependancies.
Tests need to be altered to avoid using non-included deps including
react dependancies.
2024-09-18 13:43:39 +01:00
Dan Brown
03490d6597
Lexical: Added RTL/LTR actions
Kinda useless though due to Lexical reconciler :(
2024-09-16 12:29:46 +01:00
Dan Brown
5f46d71af0
Lexical: Fixed a range of issues in RTL mode 2024-09-15 16:10:46 +01:00
Dan Brown
6872eb802c
Lexical: Altered keyboard handling to indicant handled state 2024-09-13 16:05:55 +01:00
Dan Brown
662110c269
Lexical: Custom list nesting support
Added list nesting support to allow li > ul style nesting which lexical
didn't do by default.
Adds tab handling for inset/outset controls.
Will be a range of edge-case bugs to squash during testing.
2024-09-13 15:50:42 +01:00
Dan Brown
5083188ed8
Lexical: Added block indenting capability
Needed a custom implementation due to hardcoded defaults for Lexical
default indenting.
2024-09-10 15:55:46 +01:00
Dan Brown
2036438203
Lexical: Added single node enter handling
Also updated media to be an inline element to align with old editor
behaviour.
2024-09-10 12:14:26 +01:00
Dan Brown
ced66f1671
Lexical: Added single node backspace/delete support 2024-09-09 18:33:54 +01:00
Dan Brown
fb49371c6b
Lexical: Refined editor UI
- Cleaned up dropdown lists to look integrated
- Added icons for color picker clear and menu list items
2024-09-09 14:06:41 +01:00
Dan Brown
fd07aa0f05
Lexical: Further fixes
- Improved node resizer positioning to be more accurate
- Fixed drop handling not running within editor margin space
- Made media dom update smarter to reduce reloads
- Fixed media alignment, broken due to added wrapper
2024-09-09 12:28:01 +01:00
Dan Brown
16518a4f89
Lexical: Range of bug fixes, Updated lexical version
- Updated selection change detection to be more accurate
- Added UI refresh for extra actions
- Fixed remove link deleting contents
2024-09-08 15:54:59 +01:00
Dan Brown
bed2c29a33
Lexical: Added media resize support via drag handles 2024-09-08 13:37:13 +01:00
Dan Brown
e5b6d28bca
Lexical: Revamped image node resize method
Changed from using a decorator to using a helper that watches for image
selections to then display a resize helper.
Also changes resizer to use a ghost and apply changes on end instead of
continuosly during resize.
2024-09-07 18:39:58 +01:00
Dan Brown
1c9afcb84e
Lexical: Added some level of img/media alignment 2024-09-06 14:07:10 +01:00
Dan Brown
1ebb0f8c93
Lexical: Added table column cut/copy/paste support 2024-08-22 13:28:30 +01:00
Dan Brown
8a13a9df80
Lexical: Improved table row copy/paste
Added safeguarding/matching of source/target sizes to prevent broken
tables.
2024-08-22 10:08:08 +01:00
Dan Brown
ddf5f2543c
Lexical: Added drop/paste image handling 2024-08-21 12:59:45 +01:00
Dan Brown
dbb2fe3e59
Lexical: Finished off baseline shortcut implementation 2024-08-20 14:54:53 +01:00
Dan Brown
aa1fac62d5
Lexical: Started adding editor shortcuts 2024-08-20 13:07:33 +01:00
Dan Brown
111a313d51
Lexical: Added custom alignment handling for blocks
To align with pre-existing use of alignment classes.
2024-08-18 16:51:08 +01:00
Dan Brown
0039f893cc
Lexical: Integrated diagram manager, added menu split button 2024-08-17 10:48:34 +01:00
Dan Brown
ad6b26ba97
Lexical: Added basic URL field header option list
May show bad option label names on chrome/safari.
This was an easy first pass without loads of extra custom UI since we're
using native datalists.
2024-08-16 12:29:40 +01:00
Dan Brown
1ef4044419
Lexical: Connected link selector to link form 2024-08-16 11:22:12 +01:00
Dan Brown
accf2565a0
Lexical: Integrated image manager to image button/form 2024-08-13 19:36:18 +01:00
Dan Brown
ec965f28c0
Lexical: Added id support for all main block types 2024-08-11 16:08:51 +01:00
Dan Brown
ebf95f637a
Lexical: Wired table properties, and other buttons 2024-08-10 13:14:55 +01:00
Dan Brown
abbfd42a6c
Lexical: Kinda made row copy/paste work 2024-08-09 21:58:45 +01:00
Dan Brown
db4208a7eb
Lexical: Linked row properties form up 2024-08-09 12:42:04 +01:00
Dan Brown
da54e1d87c
Lexical: Added cell width fetching, Created custom row node 2024-08-09 11:24:25 +01:00
Dan Brown
e8532ef4de
Lexical: Added merge cell logic 2024-08-07 20:32:54 +01:00
Dan Brown
fcc1c2968d
Lexical: Added table cell node import logic 2024-08-06 09:36:37 +01:00
Dan Brown
b3d3b14f79
Lexical: Finished off core cell properties functionality 2024-08-05 18:49:17 +01:00
Dan Brown
8939f310db
Lexical: Started linking up cell properties form 2024-08-05 15:08:52 +01:00
Dan Brown
efec752985
Lexical: Split helpers to utils, refactored files 2024-08-03 18:14:01 +01:00
Dan Brown
e94ad78ea7
Lexical: Completed out table menu elements, logic pending 2024-08-03 18:01:54 +01:00
Dan Brown
a27a325af7
Lexical: Started on table actions
Started building table cell form/actions
2024-08-02 15:28:54 +01:00
Dan Brown
6b06d490c5
Lexical: Started table menu options
Updated UI elements to handle new scenarios needed in more complex table
menu
2024-08-02 11:16:54 +01:00
Dan Brown
13f8f39dd5
Lexical: Updated task list to use/support old format 2024-07-30 14:42:19 +01:00
Dan Brown
fe05cff64f
Lexical: Linked up change/draft management 2024-07-29 21:43:20 +01:00
Dan Brown
d86837ac07
Lexical: Got working with attachment insert/drop 2024-07-29 21:14:42 +01:00
Dan Brown
9a7edc6e52
Lexical: Started drop handling, handled templates 2024-07-29 15:27:41 +01:00
Dan Brown
ce8c9dd079
Lexical: Added form complex/tab ui support 2024-07-28 12:48:58 +01:00
Dan Brown
c8f6b7e0d6
Lexical: Got media node core work & form done 2024-07-27 17:25:30 +01:00
Dan Brown
f284d31861
Lexical: Started media node support 2024-07-25 16:25:08 +01:00
Dan Brown
76b0d2d5d8
Lexical: Added common events support 2024-07-23 15:35:18 +01:00
Dan Brown
2cab778f19
Lexical: Improved table resize bars
Added scoll & page resize handling.
Added cropping/limiting to edit area.
2024-07-23 12:45:58 +01:00
Dan Brown
b618287585
Lexical: Added table toolbar, organised button code 2024-07-21 15:11:24 +01:00
Dan Brown
63f4b42453
Lexical: Added toolbar scroll/resize handling
Also added smarter above/below positioning to respond if toolbar would
be off the bottom of the editor, and added hide/show when they'd go
outside editor scroll bounds.
2024-07-19 18:12:51 +01:00
Dan Brown
c7c0df0964
Lexical: Finished up core drawing insert/editing
Added new options that sits on the context, for things needed but not
for the core editor, which are defined out of the editor (drawio URL,
error message text, pageId etc...)
2024-07-19 12:09:41 +01:00
Dan Brown
fb87fb5750
JS: Converted http service to ts 2024-07-18 15:13:14 +01:00
Dan Brown
634b0aaa07
Lexical: Started converting drawio to TS
Converted events service to TS as part of this.
2024-07-18 11:19:11 +01:00
Dan Brown
5002a89754
Lexical: Standardised helper function format 2024-07-17 16:45:57 +01:00
Dan Brown
b367490edc
Lexical: Added list support, started todo 2024-07-17 16:38:20 +01:00
Dan Brown
ea4c50c2c2
Lexical: Added code block selection & edit features
Also added extra lifecycle handling for decorators to things can be
properly cleaned up after node destruction.
2024-07-16 16:36:08 +01:00
Dan Brown
51d8044a54
Lexical: Added initial form/modal styles 2024-07-09 20:49:47 +01:00
Dan Brown
2c96af9aea
Lexical: Worked on toolbar styling, got format submenu working 2024-07-04 16:16:16 +01:00
Dan Brown
04c7e680fd
Lexical: Linked up saving logic of editor via interface 2024-07-04 13:09:53 +01:00
Dan Brown
a8f1160743
JS: Converted come common services to typescript 2024-07-03 11:00:57 +01:00
Dan Brown
feca1f0502
Lexical: Started diagram support 2024-07-03 10:28:04 +01:00
Dan Brown
d0a5a5ef37
Lexical: Linked code block to editor, added button 2024-07-02 17:34:03 +01:00
Dan Brown
97f570a4ee
Lexical: Started code block node implementation 2024-07-02 14:46:30 +01:00
Dan Brown
9ebbf7ce94
Lexical: Started loading real content, Improved html loading
Added more styling/layout for buttons and main content area
2024-07-01 15:10:22 +01:00
Dan Brown
c2ecbf071f
Lexical: Added tracked container, added fullscreen action
Changed how the editor is loaded in, so it now creates its own DOM, and
content is passed via creation function, to be better self-contained.
2024-07-01 10:44:23 +01:00
Dan Brown
b1c489090e
Lexical: Added context toolbar placement, added link toolbar
Also added some basic context toolbar styling
2024-06-30 19:52:09 +01:00
Dan Brown
c9a03c5b01
Lexical: Added base context toolbar logic 2024-06-30 12:13:13 +01:00
Dan Brown
517c578a5f
Lexical: Reorganised some logic into manager 2024-06-30 10:31:39 +01:00
Dan Brown
f10ec3271a
Lexical: Added overflow container 2024-06-27 16:28:06 +01:00
Dan Brown
4e2820d6e3
Lexical: Added horizontal rule node 2024-06-27 15:48:06 +01:00
Dan Brown
72a0e081ca
Lexical: Completed initial table cell resize handle logic 2024-06-26 17:22:00 +01:00
Dan Brown
b1130cb1c3
Lexical: Linked up table resize handler (unfinished) 2024-06-26 13:52:00 +01:00
Dan Brown
59936631ec
Lexical: Extracted mouse drag tracking to new helper 2024-06-25 18:33:29 +01:00
Dan Brown
3af22ce754
Lexical: Created custom table node with col width handling 2024-06-24 20:50:17 +01:00
Dan Brown
5546b8ff43
Lexical: Added more icons, made reflective text/bg color buttons 2024-06-23 15:50:41 +01:00
Dan Brown
a07092b7e6
Lexical: Updated lexical, added undo state tracking, format styles 2024-06-23 11:36:48 +01:00
Dan Brown
ac01c62e6e
Lexical: Added table creator UI 2024-06-21 16:18:44 +01:00
Dan Brown
f47f7dd9d2
Lexical: Added base table support and started resize handling 2024-06-21 13:47:47 +01:00
Dan Brown
13d970c7ce
Lexical: Added button icon system
With a bunch of default icons
2024-06-19 20:00:29 +01:00
Dan Brown
e2409a5fab
Lexical: Added basic list button/support 2024-06-19 16:14:20 +01:00
Dan Brown
9e43e03db4
Lexical: Added color picker controls 2024-06-12 19:51:42 +01:00
Dan Brown
a475cf68bf
Lexical: Added clear formatting button 2024-06-12 14:24:50 +01:00
Dan Brown
e889bc680b
Lexical: Added view/edit source code button/form/action 2024-06-12 14:01:36 +01:00
Dan Brown
5c343638b6
Added base node/button for details/summary 2024-06-06 14:43:50 +01:00
Dan Brown
0722960260
Lexical: Added selection to state for aligned reading
Connected up to work with image form
2024-06-05 18:43:42 +01:00
Dan Brown
e959c468f6
Lexical: Made image resize handles functional 2024-06-05 17:18:58 +01:00
Dan Brown
ba871ec46a
Lexical: Started image resize controls, Defined thorough decorator model 2024-06-05 13:04:49 +01:00
Dan Brown
a74e04141c
Lexical: Started build of image node and decoration UI 2024-06-03 16:56:31 +01:00
Dan Brown
7c504a10a8
Lexical: Created core modal functionality 2024-06-01 16:49:47 +01:00
Dan Brown
ae98745439
Lexical: Started on form UI 2024-05-30 16:50:55 +01:00
Dan Brown
57259aee00
Lexical: Added format previews to format buttons 2024-05-30 12:25:25 +01:00
Dan Brown
dc1a40ea74
Lexical: Added ui container type
Structured UI logical to be fairly standard and mostly covered via
a base class that handles context and core dom work.
2024-05-29 20:38:31 +01:00
Dan Brown
483d9bf26c
Lexical: Added a range of format buttons 2024-05-28 22:56:58 +01:00
Dan Brown
b24d60e98d
Lexical: Started UI fundementals with basic button 2024-05-28 18:04:48 +01:00
Dan Brown
0f8bd869d8
Lexical: Added custom id-supporting paragraph blocks 2024-05-28 15:09:50 +01:00
Dan Brown
49546cd627
Lexical: Switched to ts for new editor build 2024-05-27 23:50:28 +01:00
Dan Brown
6e852d2e65
Lexical: Played with commands, extracted & improved callout node 2024-05-27 20:23:45 +01:00
Dan Brown
5a4f595341
Editors: Added lexical editor for testing
Started basic playground for testing lexical as a new WYSIWYG editor.
Moved out tinymce to be under wysiwyg-tinymce instead so lexical is the
default, but TinyMce code remains.
2024-05-27 15:39:41 +01:00
324 changed files with 66748 additions and 1206 deletions

View File

@ -334,6 +334,11 @@ EXPORT_PAGE_SIZE=a4
# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}" # Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
EXPORT_PDF_COMMAND=false EXPORT_PDF_COMMAND=false
# Export PDF Command Timeout
# The number of seconds that the export PDF command will run before a timeout occurs.
# Only applies for the EXPORT_PDF_COMMAND option, not for DomPDF or wkhtmltopdf.
EXPORT_PDF_COMMAND_TIMEOUT=15
# Set path to wkhtmltopdf binary for PDF generation. # Set path to wkhtmltopdf binary for PDF generation.
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf' # Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
# When false, BookStack will attempt to find a wkhtmltopdf in the application # When false, BookStack will attempt to find a wkhtmltopdf in the application

View File

@ -13,9 +13,9 @@ on:
jobs: jobs:
build: build:
if: ${{ github.ref != 'refs/heads/l10n_development' }} if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-22.04 runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v4
- name: Install NPM deps - name: Install NPM deps
run: npm ci run: npm ci

29
.github/workflows/test-js.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: test-js
on:
push:
paths:
- '**.js'
- '**.ts'
- '**.json'
pull_request:
paths:
- '**.js'
- '**.ts'
- '**.json'
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install NPM deps
run: npm ci
- name: Run TypeScript type checking
run: npm run ts:lint
- name: Run JavaScript tests
run: npm run test

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
/node_modules /node_modules
/.vscode /.vscode
/composer /composer
/coverage
Homestead.yaml Homestead.yaml
.env .env
.idea .idea

View File

@ -0,0 +1,10 @@
<?php
namespace BookStack\Access;
use Exception;
class UserInviteException extends Exception
{
//
}

View File

@ -13,11 +13,17 @@ class UserInviteService extends UserTokenService
/** /**
* Send an invitation to a user to sign into BookStack * Send an invitation to a user to sign into BookStack
* Removes existing invitation tokens. * Removes existing invitation tokens.
* @throws UserInviteException
*/ */
public function sendInvitation(User $user) public function sendInvitation(User $user)
{ {
$this->deleteByUser($user); $this->deleteByUser($user);
$token = $this->createTokenForUser($user); $token = $this->createTokenForUser($user);
$user->notify(new UserInviteNotification($token));
try {
$user->notify(new UserInviteNotification($token));
} catch (\Exception $exception) {
throw new UserInviteException($exception->getMessage(), $exception->getCode(), $exception);
}
} }
} }

View File

@ -29,6 +29,10 @@ return [
// Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}" // Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
'pdf_command' => env('EXPORT_PDF_COMMAND', false), 'pdf_command' => env('EXPORT_PDF_COMMAND', false),
// The amount of time allowed for PDF generation command to run
// before the process times out and is stopped.
'pdf_command_timeout' => env('EXPORT_PDF_COMMAND_TIMEOUT', 15),
// 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support. // 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support.
'snappy' => [ 'snappy' => [
'pdf_binary' => env('WKHTMLTOPDF', false), 'pdf_binary' => env('WKHTMLTOPDF', false),

View File

@ -3,6 +3,7 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorType;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Uploads\Attachment; use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;

View File

@ -11,7 +11,7 @@ use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorData; use BookStack\Entities\Tools\PageEditorType;
use BookStack\Entities\Tools\TrashCan; use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException; use BookStack\Exceptions\PermissionsException;
@ -43,6 +43,7 @@ class PageRepo
'owned_by' => user()->id, 'owned_by' => user()->id,
'updated_by' => user()->id, 'updated_by' => user()->id,
'draft' => true, 'draft' => true,
'editor' => PageEditorType::getSystemDefault()->value,
]); ]);
if ($parent instanceof Chapter) { if ($parent instanceof Chapter) {
@ -127,7 +128,9 @@ class PageRepo
} }
$pageContent = new PageContent($page); $pageContent = new PageContent($page);
$currentEditor = $page->editor ?: PageEditorData::getSystemDefaultEditor(); $defaultEditor = PageEditorType::getSystemDefault();
$currentEditor = PageEditorType::forPage($page) ?: $defaultEditor;
$inputEditor = PageEditorType::fromRequestValue($input['editor'] ?? '') ?? $currentEditor;
$newEditor = $currentEditor; $newEditor = $currentEditor;
$haveInput = isset($input['markdown']) || isset($input['html']); $haveInput = isset($input['markdown']) || isset($input['html']);
@ -136,15 +139,17 @@ class PageRepo
if ($haveInput && $inputEmpty) { if ($haveInput && $inputEmpty) {
$pageContent->setNewHTML('', user()); $pageContent->setNewHTML('', user());
} elseif (!empty($input['markdown']) && is_string($input['markdown'])) { } elseif (!empty($input['markdown']) && is_string($input['markdown'])) {
$newEditor = 'markdown'; $newEditor = PageEditorType::Markdown;
$pageContent->setNewMarkdown($input['markdown'], user()); $pageContent->setNewMarkdown($input['markdown'], user());
} elseif (isset($input['html'])) { } elseif (isset($input['html'])) {
$newEditor = 'wysiwyg'; $newEditor = ($inputEditor->isHtmlBased() ? $inputEditor : null) ?? ($defaultEditor->isHtmlBased() ? $defaultEditor : null) ?? PageEditorType::WysiwygTinymce;
$pageContent->setNewHTML($input['html'], user()); $pageContent->setNewHTML($input['html'], user());
} }
if ($newEditor !== $currentEditor && userCan('editor-change')) { if (($newEditor !== $currentEditor || empty($page->editor)) && userCan('editor-change')) {
$page->editor = $newEditor; $page->editor = $newEditor->value;
} elseif (empty($page->editor)) {
$page->editor = $defaultEditor->value;
} }
} }

View File

@ -74,17 +74,17 @@ class PageEditorData
]; ];
} }
protected function updateContentForEditor(Page $page, string $editorType): void protected function updateContentForEditor(Page $page, PageEditorType $editorType): void
{ {
$isHtml = !empty($page->html) && empty($page->markdown); $isHtml = !empty($page->html) && empty($page->markdown);
// HTML to markdown-clean conversion // HTML to markdown-clean conversion
if ($editorType === 'markdown' && $isHtml && $this->requestedEditor === 'markdown-clean') { if ($editorType === PageEditorType::Markdown && $isHtml && $this->requestedEditor === 'markdown-clean') {
$page->markdown = (new HtmlToMarkdown($page->html))->convert(); $page->markdown = (new HtmlToMarkdown($page->html))->convert();
} }
// Markdown to HTML conversion if we don't have HTML // Markdown to HTML conversion if we don't have HTML
if ($editorType === 'wysiwyg' && !$isHtml) { if ($editorType->isHtmlBased() && !$isHtml) {
$page->html = (new MarkdownToHtml($page->markdown))->convert(); $page->html = (new MarkdownToHtml($page->markdown))->convert();
} }
} }
@ -94,24 +94,16 @@ class PageEditorData
* Defaults based upon the current content of the page otherwise will fall back * Defaults based upon the current content of the page otherwise will fall back
* to system default but will take a requested type (if provided) if permissions allow. * to system default but will take a requested type (if provided) if permissions allow.
*/ */
protected function getEditorType(Page $page): string protected function getEditorType(Page $page): PageEditorType
{ {
$editorType = $page->editor ?: self::getSystemDefaultEditor(); $editorType = PageEditorType::forPage($page) ?: PageEditorType::getSystemDefault();
// Use requested editor if valid and if we have permission // Use requested editor if valid and if we have permission
$requestedType = explode('-', $this->requestedEditor)[0]; $requestedType = PageEditorType::fromRequestValue($this->requestedEditor);
if (($requestedType === 'markdown' || $requestedType === 'wysiwyg') && userCan('editor-change')) { if ($requestedType && userCan('editor-change')) {
$editorType = $requestedType; $editorType = $requestedType;
} }
return $editorType; return $editorType;
} }
/**
* Get the configured system default editor.
*/
public static function getSystemDefaultEditor(): string
{
return setting('app-editor') === 'markdown' ? 'markdown' : 'wysiwyg';
}
} }

View File

@ -0,0 +1,37 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
enum PageEditorType: string
{
case WysiwygTinymce = 'wysiwyg';
case WysiwygLexical = 'wysiwyg2024';
case Markdown = 'markdown';
public function isHtmlBased(): bool
{
return match ($this) {
self::WysiwygTinymce, self::WysiwygLexical => true,
self::Markdown => false,
};
}
public static function fromRequestValue(string $value): static|null
{
$editor = explode('-', $value)[0];
return static::tryFrom($editor);
}
public static function forPage(Page $page): static|null
{
return static::tryFrom($page->editor);
}
public static function getSystemDefault(): static
{
$setting = setting('app-editor');
return static::tryFrom($setting) ?? static::WysiwygTinymce;
}
}

View File

@ -5,6 +5,7 @@ namespace BookStack\Entities\Tools;
use BookStack\Exceptions\PdfExportException; use BookStack\Exceptions\PdfExportException;
use Knp\Snappy\Pdf as SnappyPdf; use Knp\Snappy\Pdf as SnappyPdf;
use Dompdf\Dompdf; use Dompdf\Dompdf;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
class PdfGenerator class PdfGenerator
@ -85,9 +86,15 @@ class PdfGenerator
file_put_contents($inputHtml, $html); file_put_contents($inputHtml, $html);
$timeout = intval(config('exports.pdf_command_timeout'));
$process = Process::fromShellCommandline($command); $process = Process::fromShellCommandline($command);
$process->setTimeout(15); $process->setTimeout($timeout);
$process->run();
try {
$process->run();
} catch (ProcessTimedOutException $e) {
throw new PdfExportException("PDF Export via command failed due to timeout at {$timeout} second(s)");
}
if (!$process->isSuccessful()) { if (!$process->isSuccessful()) {
throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}"); throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}");

View File

@ -3,6 +3,7 @@
namespace BookStack\Users\Controllers; namespace BookStack\Users\Controllers;
use BookStack\Access\SocialDriverManager; use BookStack\Access\SocialDriverManager;
use BookStack\Access\UserInviteException;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\UserUpdateException; use BookStack\Exceptions\UserUpdateException;
use BookStack\Http\Controller; use BookStack\Http\Controller;
@ -14,6 +15,7 @@ use BookStack\Util\SimpleListOptions;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -91,9 +93,15 @@ class UserController extends Controller
$validated = $this->validate($request, array_filter($validationRules)); $validated = $this->validate($request, array_filter($validationRules));
DB::transaction(function () use ($validated, $sendInvite) { try {
$this->userRepo->create($validated, $sendInvite); DB::transaction(function () use ($validated, $sendInvite) {
}); $this->userRepo->create($validated, $sendInvite);
});
} catch (UserInviteException $e) {
Log::error("Failed to send user invite with error: {$e->getMessage()}");
$this->showErrorNotification(trans('errors.users_could_not_send_invite'));
return redirect('/settings/users/create')->withInput();
}
return redirect('/settings/users'); return redirect('/settings/users');
} }

View File

@ -2,6 +2,7 @@
namespace BookStack\Users; namespace BookStack\Users;
use BookStack\Access\UserInviteException;
use BookStack\Access\UserInviteService; use BookStack\Access\UserInviteService;
use BookStack\Activity\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Entities\EntityProvider; use BookStack\Entities\EntityProvider;
@ -83,6 +84,7 @@ class UserRepo
* As per "createWithoutActivity" but records a "create" activity. * As per "createWithoutActivity" but records a "create" activity.
* *
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data * @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
* @throws UserInviteException
*/ */
public function create(array $data, bool $sendInvite = false): User public function create(array $data, bool $sendInvite = false): User
{ {

View File

@ -16,9 +16,9 @@
"ext-json": "*", "ext-json": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-xml": "*", "ext-xml": "*",
"bacon/bacon-qr-code": "^2.0", "bacon/bacon-qr-code": "^3.0",
"doctrine/dbal": "^3.5", "doctrine/dbal": "^3.5",
"dompdf/dompdf": "^2.0", "dompdf/dompdf": "^3.0",
"guzzlehttp/guzzle": "^7.4", "guzzlehttp/guzzle": "^7.4",
"intervention/image": "^3.5", "intervention/image": "^3.5",
"knplabs/knp-snappy": "^1.5", "knplabs/knp-snappy": "^1.5",

801
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Ensure we have an "editor" value set for pages
// Get default
$default = DB::table('settings')
->where('setting_key', '=', 'app-editor')
->first()
->value ?? 'wysiwyg';
$default = ($default === 'markdown') ? 'markdown' : 'wysiwyg';
// We set it to 'markdown' for pages currently with markdown content
DB::table('pages')
->where('editor', '=', '')
->where('markdown', '!=', '')
->update(['editor' => 'markdown']);
// We set it to 'wysiwyg' where we have HTML but no markdown
DB::table('pages')
->where('editor', '=', '')
->where('markdown', '=', '')
->where('html', '!=', '')
->update(['editor' => 'wysiwyg']);
// Otherwise, where still empty, set to the current default
DB::table('pages')
->where('editor', '=', '')
->update(['editor' => $default]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Can't reverse due to not knowing what would have been empty before
}
};

View File

@ -14,6 +14,7 @@ const entryPoints = {
code: path.join(__dirname, '../../resources/js/code/index.mjs'), code: path.join(__dirname, '../../resources/js/code/index.mjs'),
'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'), 'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'), markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'),
wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
}; };
// Locate our output directory // Locate our output directory
@ -31,6 +32,15 @@ esbuild.build({
format: 'esm', format: 'esm',
minify: isProd, minify: isProd,
logLevel: 'info', logLevel: 'info',
loader: {
'.svg': 'text',
},
absWorkingDir: path.join(__dirname, '../..'),
alias: {
'@icons': './resources/icons',
lexical: './resources/js/wysiwyg/lexical/core',
'@lexical': './resources/js/wysiwyg/lexical',
},
banner: { banner: {
js: '// See the "/licenses" URI for full package license details', js: '// See the "/licenses" URI for full package license details',
css: '/* See the "/licenses" URI for full package license details */', css: '/* See the "/licenses" URI for full package license details */',

File diff suppressed because it is too large Load Diff

View File

@ -128,7 +128,7 @@ Link: https://github.com/fruitcake/php-cors
graham-campbell/result-type graham-campbell/result-type
License: MIT License: MIT
License File: vendor/graham-campbell/result-type/LICENSE License File: vendor/graham-campbell/result-type/LICENSE
Copyright: Copyright (c) 2020-2023 Graham Campbell <*****@**********.**.**> Copyright: Copyright (c) 2020-2024 Graham Campbell <*****@**********.**.**>
Source: https://github.com/GrahamCampbell/Result-Type.git Source: https://github.com/GrahamCampbell/Result-Type.git
Link: https://github.com/GrahamCampbell/Result-Type.git Link: https://github.com/GrahamCampbell/Result-Type.git
----------- -----------
@ -676,13 +676,6 @@ Copyright: Copyright (c) 2015-present Fabien Potencier
Source: https://github.com/symfony/polyfill-mbstring.git Source: https://github.com/symfony/polyfill-mbstring.git
Link: https://symfony.com Link: https://symfony.com
----------- -----------
symfony/polyfill-php72
License: MIT
License File: vendor/symfony/polyfill-php72/LICENSE
Copyright: Copyright (c) 2015-present Fabien Potencier
Source: https://github.com/symfony/polyfill-php72.git
Link: https://symfony.com
-----------
symfony/polyfill-php80 symfony/polyfill-php80
License: MIT License: MIT
License File: vendor/symfony/polyfill-php80/LICENSE License File: vendor/symfony/polyfill-php80/LICENSE

209
jest.config.ts Normal file
View File

@ -0,0 +1,209 @@
/**
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
import type {Config} from 'jest';
import {pathsToModuleNameMapper} from "ts-jest";
import { compilerOptions } from './tsconfig.json';
const config: Config = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/tmp/jest_rs",
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: "v8",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// The default configuration for fake timers
// fakeTimers: {
// "enableGlobally": false
// },
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
globals: {
__DEV__: true,
},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "mjs",
// "cjs",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
modulePaths: ['./'],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
moduleNameMapper: {
'lexical/shared/invariant': 'resources/js/wysiwyg/lexical/core/shared/__mocks__/invariant',
...pathsToModuleNameMapper(compilerOptions.paths),
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
roots: [
"./resources/js"
],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: "jsdom",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
testMatch: [
"**/__tests__/**/*.test.[jt]s",
],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// A map from regular expressions to paths to transformers
transform: {
"^.+.tsx?$": ["ts-jest",{}],
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};
export default config;

View File

@ -224,6 +224,8 @@ return [
'pages_edit_switch_to_markdown_clean' => '(Clean Content)', 'pages_edit_switch_to_markdown_clean' => '(Clean Content)',
'pages_edit_switch_to_markdown_stable' => '(Stable Content)', 'pages_edit_switch_to_markdown_stable' => '(Stable Content)',
'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor', 'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor',
'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG',
'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)',
'pages_edit_set_changelog' => 'Set Changelog', 'pages_edit_set_changelog' => 'Set Changelog',
'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made', 'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
'pages_edit_enter_changelog' => 'Enter Changelog', 'pages_edit_enter_changelog' => 'Enter Changelog',

View File

@ -78,6 +78,7 @@ return [
// Users // Users
'users_cannot_delete_only_admin' => 'You cannot delete the only admin', 'users_cannot_delete_only_admin' => 'You cannot delete the only admin',
'users_cannot_delete_guest' => 'You cannot delete the guest user', 'users_cannot_delete_guest' => 'You cannot delete the guest user',
'users_could_not_send_invite' => 'Could not create user since invite email failed to send',
// Roles // Roles
'role_cannot_be_edited' => 'This role cannot be edited', 'role_cannot_be_edited' => 'This role cannot be edited',

View File

@ -89,7 +89,7 @@ return [
'user_create_notification' => 'Kullanıcı başarıyla oluşturuldu', 'user_create_notification' => 'Kullanıcı başarıyla oluşturuldu',
'user_update' => 'updated user', 'user_update' => 'updated user',
'user_update_notification' => 'Kullanıcı başarıyla güncellendi', 'user_update_notification' => 'Kullanıcı başarıyla güncellendi',
'user_delete' => 'deleted user', 'user_delete' => 'kullanıcı silindi',
'user_delete_notification' => 'Kullanıcı başarıyla silindi', 'user_delete_notification' => 'Kullanıcı başarıyla silindi',
// API Tokens // API Tokens

View File

@ -6,7 +6,7 @@ return [
'new_comment_subject' => 'New comment on page: :pageName', 'new_comment_subject' => 'New comment on page: :pageName',
'new_comment_intro' => 'A user has commented on a page in :appName:', 'new_comment_intro' => 'A user has commented on a page in :appName:',
'new_page_subject' => 'New page: :pageName', 'new_page_subject' => 'Yeni sayfa :pageName',
'new_page_intro' => 'A new page has been created in :appName:', 'new_page_intro' => 'A new page has been created in :appName:',
'updated_page_subject' => 'Updated page: :pageName', 'updated_page_subject' => 'Updated page: :pageName',
'updated_page_intro' => 'A page has been updated in :appName:', 'updated_page_intro' => 'A page has been updated in :appName:',

4014
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
"build:css:watch": "sass ./resources/sass:./public/dist --watch --embed-sources", "build:css:watch": "sass ./resources/sass:./public/dist --watch --embed-sources",
"build:css:production": "sass ./resources/sass:./public/dist -s compressed", "build:css:production": "sass ./resources/sass:./public/dist -s compressed",
"build:js:dev": "node dev/build/esbuild.js", "build:js:dev": "node dev/build/esbuild.js",
"build:js:watch": "chokidar --initial \"./resources/**/*.js\" \"./resources/**/*.mjs\" -c \"npm run build:js:dev\"", "build:js:watch": "chokidar --initial \"./resources/**/*.js\" \"./resources/**/*.mjs\" \"./resources/**/*.ts\" -c \"npm run build:js:dev\"",
"build:js:production": "node dev/build/esbuild.js production", "build:js:production": "node dev/build/esbuild.js production",
"build": "npm-run-all --parallel build:*:dev", "build": "npm-run-all --parallel build:*:dev",
"production": "npm-run-all --parallel build:*:production", "production": "npm-run-all --parallel build:*:production",
@ -14,7 +14,9 @@
"livereload": "livereload ./public/dist/", "livereload": "livereload ./public/dist/",
"permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads", "permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads",
"lint": "eslint \"resources/**/*.js\" \"resources/**/*.mjs\"", "lint": "eslint \"resources/**/*.js\" \"resources/**/*.mjs\"",
"fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"" "fix": "eslint --fix \"resources/**/*.js\" \"resources/**/*.mjs\"",
"ts:lint": "tsc --noEmit",
"test": "jest"
}, },
"devDependencies": { "devDependencies": {
"@lezer/generator": "^1.5.1", "@lezer/generator": "^1.5.1",
@ -23,9 +25,14 @@
"eslint": "^8.55.0", "eslint": "^8.55.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.29.0", "eslint-plugin-import": "^2.29.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"livereload": "^0.9.3", "livereload": "^0.9.3",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"sass": "^1.69.5" "sass": "^1.69.5",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "5.6.*"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.3.2", "@codemirror/commands": "^6.3.2",
@ -44,6 +51,7 @@
"@lezer/highlight": "^1.2.0", "@lezer/highlight": "^1.2.0",
"@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
"@ssddanbrown/codemirror-lang-twig": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0",
"@types/jest": "^29.5.13",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
@ -59,7 +67,8 @@
}, },
"extends": "airbnb-base", "extends": "airbnb-base",
"ignorePatterns": [ "ignorePatterns": [
"resources/**/*-stub.js" "resources/**/*-stub.js",
"resources/**/*.ts"
], ],
"overrides": [], "overrides": [],
"parserOptions": { "parserOptions": {

View File

@ -152,6 +152,7 @@ Note: This is not an exhaustive list of all libraries and projects that would be
* [Laravel](http://laravel.com/) - _[MIT](https://github.com/laravel/framework/blob/v8.82.0/LICENSE.md)_ * [Laravel](http://laravel.com/) - _[MIT](https://github.com/laravel/framework/blob/v8.82.0/LICENSE.md)_
* [TinyMCE](https://www.tinymce.com/) - _[MIT](https://github.com/tinymce/tinymce/blob/develop/LICENSE.TXT)_ * [TinyMCE](https://www.tinymce.com/) - _[MIT](https://github.com/tinymce/tinymce/blob/develop/LICENSE.TXT)_
* [Lexical](https://lexical.dev/) - _[MIT](https://github.com/facebook/lexical/blob/main/LICENSE)_
* [CodeMirror](https://codemirror.net) - _[MIT](https://github.com/codemirror/CodeMirror/blob/master/LICENSE)_ * [CodeMirror](https://codemirror.net) - _[MIT](https://github.com/codemirror/CodeMirror/blob/master/LICENSE)_
* [Sortable](https://github.com/SortableJS/Sortable) - _[MIT](https://github.com/SortableJS/Sortable/blob/master/LICENSE)_ * [Sortable](https://github.com/SortableJS/Sortable) - _[MIT](https://github.com/SortableJS/Sortable/blob/master/LICENSE)_
* [Google Material Icons](https://github.com/google/material-design-icons) - _[Apache-2.0](https://github.com/google/material-design-icons/blob/master/LICENSE)_ * [Google Material Icons](https://github.com/google/material-design-icons) - _[Apache-2.0](https://github.com/google/material-design-icons/blob/master/LICENSE)_

View File

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m2 6.9159 10 10.168 10-10.168z" stroke-width="2.0168"/></svg>

After

Width:  |  Height:  |  Size: 131 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.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"/><path fill="none" d="M0 0h24v24H0z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.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"/></svg>

Before

Width:  |  Height:  |  Size: 216 B

After

Width:  |  Height:  |  Size: 179 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-120v-80h720v80H120Zm160-160v-80h400v80H280ZM120-440v-80h720v80H120Zm160-160v-80h400v80H280ZM120-760v-80h720v80H120Z"/></svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-120v-80h720v80H120Zm0-160v-80h720v80H120Zm0-160v-80h720v80H120Zm0-160v-80h720v80H120Zm0-160v-80h720v80H120Z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-120v-80h720v80H120Zm0-160v-80h480v80H120Zm0-160v-80h720v80H120Zm0-160v-80h480v80H120Zm0-160v-80h720v80H120Z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-760v-80h720v80H120Zm240 160v-80h480v80H360ZM120-440v-80h720v80H120Zm240 160v-80h480v80H360ZM120-120v-80h720v80H120Z"/></svg>

After

Width:  |  Height:  |  Size: 203 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M272-200v-560h221q65 0 120 40t55 111q0 51-23 78.5T602-491q25 11 55.5 41t30.5 90q0 89-65 124.5T501-200H272Zm121-112h104q48 0 58.5-24.5T566-372q0-11-10.5-35.5T494-432H393v120Zm0-228h93q33 0 48-17t15-38q0-24-17-39t-44-15h-95v109Z"/></svg>

After

Width:  |  Height:  |  Size: 309 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m384-336 56-57-87-87 87-87-56-57-144 144 144 144Zm192 0 144-144-144-144-56 57 87 87-87 87 56 57ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z"/></svg>

After

Width:  |  Height:  |  Size: 336 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M320-240 80-480l240-240 57 57-184 184 183 183-56 56Zm320 0-57-57 184-184-183-183 56-56 240 240-240 240Z"/></svg>

After

Width:  |  Height:  |  Size: 186 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M800-436q0 36-8 69t-22 63l-62-60q6-17 9-34.5t3-37.5q0-47-17.5-89T650-600L480-768l-88 86-56-56 144-142 226 222q44 42 69 99.5T800-436Zm-8 380L668-180q-41 29-88 44.5T480-120q-133 0-226.5-92.5T160-436q0-51 16-98t48-90L56-792l56-56 736 736-56 56ZM480-200q36 0 68.5-10t61.5-28L280-566q-21 32-30.5 64t-9.5 66q0 98 70 167t170 69Zm-37-204Zm110-116Z"/></svg>

After

Width:  |  Height:  |  Size: 422 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-480H200v480Zm80-280v-80h400v80H280Zm0 160v-80h240v80H280Z"/></svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M480-60q-63 0-106.5-43.5T330-210q0-52 31-91.5t79-53.5v-85H200v-160H100v-280h280v280H280v80h400v-85q-48-14-79-53.5T570-750q0-63 43.5-106.5T720-900q63 0 106.5 43.5T870-750q0 52-31 91.5T760-605v165H520v85q48 14 79 53.5t31 91.5q0 63-43.5 106.5T480-60Zm240-620q29 0 49.5-20.5T790-750q0-29-20.5-49.5T720-820q-29 0-49.5 20.5T650-750q0 29 20.5 49.5T720-680Zm-540 0h120v-120H180v120Zm300 540q29 0 49.5-20.5T550-210q0-29-20.5-49.5T480-280q-29 0-49.5 20.5T410-210q0 29 20.5 49.5T480-140ZM240-740Zm480-10ZM480-210Z"/></svg>

After

Width:  |  Height:  |  Size: 585 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M440-800v400q0 17-11.5 28.5T400-360q-17 0-28.5-11.5T360-400v-160q-66 0-113-47t-47-113q0-66 47-113t113-47h280q17 0 28.5 11.5T680-840q0 17-11.5 28.5T640-800h-40v400q0 17-11.5 28.5T560-360q-17 0-28.5-11.5T520-400v-400h-80Zm-80 160v-160q-33 0-56.5 23.5T280-720q0 33 23.5 56.5T360-640Zm0-80Zm328 520H160q-17 0-28.5-11.5T120-240q0-17 11.5-28.5T160-280h528l-36-36q-11-11-11-28t11-28q11-11 28-11t28 11l104 104q12 12 12 28t-12 28L708-108q-11 11-28 11t-28-11q-11-11-11-28t11-28l36-36Z"/></svg>

After

Width:  |  Height:  |  Size: 557 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M440-800v400q0 17-11.5 28.5T400-360q-17 0-28.5-11.5T360-400v-160q-66 0-113-47t-47-113q0-66 47-113t113-47h280q17 0 28.5 11.5T680-840q0 17-11.5 28.5T640-800h-40v400q0 17-11.5 28.5T560-360q-17 0-28.5-11.5T520-400v-400h-80ZM272-200l36 36q11 11 11 28t-11 28q-11 11-28 11t-28-11L148-212q-12-12-12-28t12-28l104-104q11-11 28-11t28 11q11 11 11 28t-11 28l-36 36h528q17 0 28.5 11.5T840-240q0 17-11.5 28.5T800-200H272Zm88-440v-160q-33 0-56.5 23.5T280-720q0 33 23.5 56.5T360-640Zm0-80Z"/></svg>

After

Width:  |  Height:  |  Size: 555 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m528-546-93-93-121-121h486v120H568l-40 94ZM792-56 460-388l-80 188H249l119-280L56-792l56-56 736 736-56 56Z"/></svg>

After

Width:  |  Height:  |  Size: 188 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-120v-200h80v120h120v80H120Zm520 0v-80h120v-120h80v200H640ZM120-640v-200h200v80H200v120h-80Zm640 0v-120H640v-80h200v200h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 211 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M478-240q21 0 35.5-14.5T528-290q0-21-14.5-35.5T478-340q-21 0-35.5 14.5T428-290q0 21 14.5 35.5T478-240Zm-36-154h74q0-33 7.5-52t42.5-52q26-26 41-49.5t15-56.5q0-56-41-86t-97-30q-57 0-92.5 30T342-618l66 26q5-18 22.5-39t53.5-21q32 0 48 17.5t16 38.5q0 20-12 37.5T506-526q-44 39-54 59t-10 73Zm38 314q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 653 B

View File

@ -0,0 +1 @@
<svg version="1.1" viewBox="0 -960 960 960" xmlns="http://www.w3.org/2000/svg"><path class="editor-icon-color-bar" d="m80-2e-6v-160h800v160z"/><path d="m584-480-104-104-160 160 103 104zm-47-160 103 103 160-159-104-104zm-84-29 216 216-189 190c-16 16-34.833 24-56.5 24s-40.5-8-56.5-24l-27 23h-200l126-125c-16-16-24.333-35.167-25-57.5s7-41.5 23-57.5zm0 0 187-187c16-16 34.833-24 56.5-24s40.5 8 56.5 24l104 103c16 16 24 34.833 24 56.5s-8 40.5-24 56.5l-188 187z"/></svg>

After

Width:  |  Height:  |  Size: 465 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M160-440v-80h640v80H160Z"/></svg>

After

Width:  |  Height:  |  Size: 107 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h200v80H200v560h560v-214l80 80v134q0 33-23.5 56.5T760-120H200Zm40-160 120-160 90 120 120-160 150 200H240Zm622-144L738-548q-21 14-45 21t-51 7q-74 0-126-52.5T464-700q0-75 52.5-127.5T644-880q75 0 127.5 52.5T824-700q0 27-8 52t-20 46l122 122-56 56ZM644-600q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29Z"/></svg>

After

Width:  |  Height:  |  Size: 466 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h360v80H200v560h560v-360h80v360q0 33-23.5 56.5T760-120H200Zm480-480v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80ZM240-280h480L570-480 450-320l-90-120-120 160Zm-40-480v560-560Z"/></svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-120v-80h720v80H120Zm320-160v-80h400v80H440Zm0-160v-80h400v80H440Zm0-160v-80h400v80H440ZM120-760v-80h720v80H120Zm160 440L120-480l160-160v320Z"/></svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-120v-80h720v80H120Zm320-160v-80h400v80H440Zm0-160v-80h400v80H440Zm0-160v-80h400v80H440ZM120-760v-80h720v80H120Zm0 440v-320l160 160-160 160Z"/></svg>

After

Width:  |  Height:  |  Size: 227 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-200v-100h160l120-360H320v-100h400v100H580L460-300h140v100H200Z"/></svg>

After

Width:  |  Height:  |  Size: 150 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M680-160v-120H560v-80h120v-120h80v120h120v80H760v120h-80ZM440-280H280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h160v80H280q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h320v80H320Zm560-40h-80q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480Z"/></svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M360-200v-80h480v80H360Zm0-240v-80h480v80H360Zm0-240v-80h480v80H360ZM200-160q-33 0-56.5-23.5T120-240q0-33 23.5-56.5T200-320q33 0 56.5 23.5T280-240q0 33-23.5 56.5T200-160Zm0-240q-33 0-56.5-23.5T120-480q0-33 23.5-56.5T200-560q33 0 56.5 23.5T280-480q0 33-23.5 56.5T200-400Zm0-240q-33 0-56.5-23.5T120-720q0-33 23.5-56.5T200-800q33 0 56.5 23.5T280-720q0 33-23.5 56.5T200-640Z"/></svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M222-200 80-342l56-56 85 85 170-170 56 57-225 226Zm0-320L80-662l56-56 85 85 170-170 56 57-225 226Zm298 240v-80h360v80H520Zm0-320v-80h360v80H520Z"/></svg>

After

Width:  |  Height:  |  Size: 227 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-80v-60h100v-30h-60v-60h60v-30H120v-60h120q17 0 28.5 11.5T280-280v40q0 17-11.5 28.5T240-200q17 0 28.5 11.5T280-160v40q0 17-11.5 28.5T240-80H120Zm0-280v-110q0-17 11.5-28.5T160-510h60v-30H120v-60h120q17 0 28.5 11.5T280-560v70q0 17-11.5 28.5T240-450h-60v30h100v60H120Zm60-280v-180h-60v-60h120v240h-60Zm180 440v-80h480v80H360Zm0-240v-80h480v80H360Zm0-240v-80h480v80H360Z"/></svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m380-300 280-180-280-180v360ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z"/></svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M240-400q-33 0-56.5-23.5T160-480q0-33 23.5-56.5T240-560q33 0 56.5 23.5T320-480q0 33-23.5 56.5T240-400Zm240 0q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm240 0q-33 0-56.5-23.5T640-480q0-33 23.5-56.5T720-560q33 0 56.5 23.5T800-480q0 33-23.5 56.5T720-400Z"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" ><path d="M396-200q-97 0-166.5-63T160-420q0-94 69.5-157T396-640h252L544-744l56-56 200 200-200 200-56-56 104-104H396q-63 0-109.5 40T240-420q0 60 46.5 100T396-280h284v80H396Z"/></svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m384-336 56-58-86-86 86-86-56-58-144 144 144 144Zm192 0 144-144-144-144-56 58 86 86-86 86 56 58ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h168q13-36 43.5-58t68.5-22q38 0 68.5 22t43.5 58h168q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm280-590q13 0 21.5-8.5T510-820q0-13-8.5-21.5T480-850q-13 0-21.5 8.5T450-820q0 13 8.5 21.5T480-790ZM200-200v-560 560Z"/></svg>

After

Width:  |  Height:  |  Size: 484 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M80-400v-80h800v80H80Zm340-160v-120H200v-120h560v120H540v120H420Zm0 400v-160h120v160H420Z"/></svg>

After

Width:  |  Height:  |  Size: 172 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M760-160v-80q0-17 11.5-28.5T800-280h80v-40H760v-40h120q17 0 28.5 11.5T920-320v40q0 17-11.5 28.5T880-240h-80v40h120v40H760Zm-525-80 185-291-172-269h106l124 200h4l123-200h107L539-531l186 291H618L482-457h-4L342-240H235Z"/></svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M760-600v-80q0-17 11.5-28.5T800-720h80v-40H760v-40h120q17 0 28.5 11.5T920-760v40q0 17-11.5 28.5T880-680h-80v40h120v40H760ZM235-160l185-291-172-269h106l124 200h4l123-200h107L539-451l186 291H618L482-377h-4L342-160H235Z"/></svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14c1.1 0 2 .9 2 2zm-2 0V5h-4v2.2h-2V5h-2v2.2H9V5H5v14h4v-2.1h2V19h2v-2.1h2V19Z"/><path d="M14.829 10.585 13.415 12l1.414 1.414c.943.943-.472 2.357-1.414 1.414L12 13.414l-1.414 1.414c-.944.944-2.358-.47-1.414-1.414L10.586 12l-1.414-1.415c-.943-.942.471-2.357 1.414-1.414L12 10.585l1.344-1.343c1.111-1.112 2.2.627 1.485 1.343z" style="fill-rule:nonzero"/></svg>

After

Width:  |  Height:  |  Size: 481 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 21a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14c0 1.1-.9 2-2 2zm0-2h14v-4h-2.2v-2H19v-2h-2.2V9H19V5H5v4h2.1v2H5v2h2.1v2H5Z"/><path d="M13.415 14.829 12 13.415l-1.414 1.414c-.943.943-2.357-.472-1.414-1.414L10.586 12l-1.414-1.414c-.944-.944.47-2.358 1.414-1.414L12 10.586l1.415-1.414c.942-.943 2.357.471 1.414 1.414L13.415 12l1.343 1.344c1.112 1.111-.627 2.2-1.343 1.485z" style="fill-rule:nonzero"/></svg>

After

Width:  |  Height:  |  Size: 481 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 21a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14c0 1.1-.9 2-2 2zm0-2h14V5H5v14z"/><path d="m13.711 15.423-1.71-1.712-1.712 1.712c-1.14 1.14-2.852-.57-1.71-1.712l1.71-1.71-1.71-1.712c-1.143-1.142.568-2.853 1.71-1.71L12 10.288l1.711-1.71c1.141-1.142 2.852.57 1.712 1.71L13.71 12l1.626 1.626c1.345 1.345-.76 2.663-1.626 1.797z" style="fill-rule:nonzero;stroke-width:1.20992"/></svg>

After

Width:  |  Height:  |  Size: 455 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M16 5h-5v14h5c1.235 0 1.234 2 0 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11c1.229 0 1.236 2 0 2zm-7 6V5H5v6zm0 8v-6H5v6zm11.076-6h-2v2c0 1.333-2 1.333-2 0v-2h-2c-1.335 0-1.335-2 0-2h2V9c0-1.333 2-1.333 2 0v2h1.9c1.572 0 1.113 2 .1 2z"/></svg>

After

Width:  |  Height:  |  Size: 304 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8 19h5V5H8C6.764 5 6.766 3 8 3h11a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H8c-1.229 0-1.236-2 0-2zm7-6v6h4v-6zm0-8v6h4V5ZM3.924 11h2V9c0-1.333 2-1.333 2 0v2h2c1.335 0 1.335 2 0 2h-2v2c0 1.333-2 1.333-2 0v-2h-1.9c-1.572 0-1.113-2-.1-2z"/></svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 8v5h14V8c0-1.235 2-1.234 2 0v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8C3 6.77 5 6.764 5 8zm6 7H5v4h6zm8 0h-6v4h6zM13 3.924v2h2c1.333 0 1.333 2 0 2h-2v2c0 1.335-2 1.335-2 0v-2H9c-1.333 0-1.333-2 0-2h2v-1.9c0-1.572 2-1.113 2-.1z"/></svg>

After

Width:  |  Height:  |  Size: 300 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 16v-5H5v5c0 1.235-2 1.234-2 0V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11c0 1.229-2 1.236-2 0zm-6-7h6V5h-6zM5 9h6V5H5Zm6 11.076v-2H9c-1.333 0-1.333-2 0-2h2v-2c0-1.335 2-1.335 2 0v2h2c1.333 0 1.333 2 0 2h-2v1.9c0 1.572-2 1.113-2 .1z"/></svg>

After

Width:  |  Height:  |  Size: 305 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200q-33 0-56.5-23.5T120-200Zm80-400h560v-160H200v160Zm213 200h134v-120H413v120Zm0 200h134v-120H413v120ZM200-400h133v-120H200v120Zm427 0h133v-120H627v120ZM200-200h133v-120H200v120Zm427 0h133v-120H627v120Z"/></svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@ -0,0 +1 @@
<svg version="1.1" viewBox="0 -960 960 960" xmlns="http://www.w3.org/2000/svg"><path class="editor-icon-color-bar" d="m80-3e-6v-160h800v160z"/><path d="m220-280 210-560h100l210 560h-96l-50-144h-226l-52 144zm176-224h168l-82-232h-4z"/></svg>

After

Width:  |  Height:  |  Size: 240 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M200-120v-80h560v80H200Zm280-160q-101 0-157-63t-56-167v-330h103v336q0 56 28 91t82 35q54 0 82-35t28-91v-336h103v330q0 104-56 167t-157 63Z"/></svg>

After

Width:  |  Height:  |  Size: 219 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="M280-200v-80h284q63 0 109.5-40T720-420q0-60-46.5-100T564-560H312l104 104-56 56-200-200 200-200 56 56-104 104h252q97 0 166.5 63T800-420q0 94-69.5 157T564-200H280Z"/></svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m770-302-60-62q40-11 65-42.5t25-73.5q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480q0 57-29.5 105T770-302ZM634-440l-80-80h86v80h-6ZM792-56 56-792l56-56 736 736-56 56ZM440-280H280q-83 0-141.5-58.5T80-480q0-69 42-123t108-71l74 74h-24q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h65l79 80H320Z"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -1,9 +1,11 @@
import * as events from './services/events'; import {EventManager} from './services/events.ts';
import * as httpInstance from './services/http'; import {HttpManager} from './services/http.ts';
import Translations from './services/translations'; import Translations from './services/translations';
import * as components from './services/components';
import * as componentMap from './components'; import * as componentMap from './components';
import {ComponentStore} from './services/components.ts';
// eslint-disable-next-line no-underscore-dangle
window.__DEV__ = false;
// Url retrieval function // Url retrieval function
window.baseUrl = function baseUrl(path) { window.baseUrl = function baseUrl(path) {
@ -21,8 +23,8 @@ window.importVersioned = function importVersioned(moduleName) {
}; };
// Set events and http services on window // Set events and http services on window
window.$http = httpInstance; window.$http = new HttpManager();
window.$events = events; window.$events = new EventManager();
// Translation setup // Translation setup
// Creates a global function with name 'trans' to be used in the same way as the Laravel translation system // Creates a global function with name 'trans' to be used in the same way as the Laravel translation system
@ -32,6 +34,6 @@ window.trans_choice = translator.getPlural.bind(translator);
window.trans_plural = translator.parsePlural.bind(translator); window.trans_plural = translator.parsePlural.bind(translator);
// Load & initialise components // Load & initialise components
components.register(componentMap); window.$components = new ComponentStore();
window.$components = components; window.$components.register(componentMap);
components.init(); window.$components.init();

View File

@ -58,4 +58,5 @@ export {TriLayout} from './tri-layout';
export {UserSelect} from './user-select'; export {UserSelect} from './user-select';
export {WebhookEvents} from './webhook-events'; export {WebhookEvents} from './webhook-events';
export {WysiwygEditor} from './wysiwyg-editor'; export {WysiwygEditor} from './wysiwyg-editor';
export {WysiwygEditorTinymce} from './wysiwyg-editor-tinymce';
export {WysiwygInput} from './wysiwyg-input'; export {WysiwygInput} from './wysiwyg-input';

View File

@ -133,9 +133,9 @@ export class MarkdownEditor extends Component {
/** /**
* Get the content of this editor. * Get the content of this editor.
* Used by the parent page editor component. * Used by the parent page editor component.
* @return {{html: String, markdown: String}} * @return {Promise<{html: String, markdown: String}>}
*/ */
getContent() { async getContent() {
return this.editor.actions.getContent(); return this.editor.actions.getContent();
} }

View File

@ -1,6 +1,6 @@
import {Component} from './component'; import {Component} from './component';
import {getLoading, htmlToDom} from '../services/dom'; import {getLoading, htmlToDom} from '../services/dom';
import {buildForInput} from '../wysiwyg/config'; import {buildForInput} from '../wysiwyg-tinymce/config';
export class PageComment extends Component { export class PageComment extends Component {

View File

@ -1,6 +1,6 @@
import {Component} from './component'; import {Component} from './component';
import {getLoading, htmlToDom} from '../services/dom'; import {getLoading, htmlToDom} from '../services/dom';
import {buildForInput} from '../wysiwyg/config'; import {buildForInput} from '../wysiwyg-tinymce/config';
export class PageComments extends Component { export class PageComments extends Component {

View File

@ -118,7 +118,7 @@ export class PageEditor extends Component {
async saveDraft() { async saveDraft() {
const data = {name: this.titleElem.value.trim()}; const data = {name: this.titleElem.value.trim()};
const editorContent = this.getEditorComponent().getContent(); const editorContent = await this.getEditorComponent().getContent();
Object.assign(data, editorContent); Object.assign(data, editorContent);
let didSave = false; let didSave = false;
@ -235,10 +235,12 @@ export class PageEditor extends Component {
} }
/** /**
* @return MarkdownEditor|WysiwygEditor * @return {MarkdownEditor|WysiwygEditor|WysiwygEditorTinymce}
*/ */
getEditorComponent() { getEditorComponent() {
return window.$components.first('markdown-editor') || window.$components.first('wysiwyg-editor'); return window.$components.first('markdown-editor')
|| window.$components.first('wysiwyg-editor')
|| window.$components.first('wysiwyg-editor-tinymce');
} }
} }

View File

@ -25,7 +25,7 @@ export class Shortcuts extends Component {
setupListeners() { setupListeners() {
window.addEventListener('keydown', event => { window.addEventListener('keydown', event => {
if (event.target.closest('input, select, textarea, .cm-editor')) { if (event.target.closest('input, select, textarea, .cm-editor, .editor-container')) {
return; return;
} }

View File

@ -0,0 +1,48 @@
import {buildForEditor as buildEditorConfig} from '../wysiwyg-tinymce/config';
import {Component} from './component';
export class WysiwygEditorTinymce extends Component {
setup() {
this.elem = this.$el;
this.tinyMceConfig = buildEditorConfig({
language: this.$opts.language,
containerElement: this.elem,
darkMode: document.documentElement.classList.contains('dark-mode'),
textDirection: this.$opts.textDirection,
drawioUrl: this.getDrawIoUrl(),
pageId: Number(this.$opts.pageId),
translations: {
imageUploadErrorText: this.$opts.imageUploadErrorText,
serverUploadLimitText: this.$opts.serverUploadLimitText,
},
translationMap: window.editor_translations,
});
window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig});
window.tinymce.init(this.tinyMceConfig).then(editors => {
this.editor = editors[0];
});
}
getDrawIoUrl() {
const drawioUrlElem = document.querySelector('[drawio-url]');
if (drawioUrlElem) {
return drawioUrlElem.getAttribute('drawio-url');
}
return '';
}
/**
* Get the content of this editor.
* Used by the parent page editor component.
* @return {Promise<{html: String}>}
*/
async getContent() {
return {
html: this.editor.getContent(),
};
}
}

View File

@ -1,28 +1,48 @@
import {buildForEditor as buildEditorConfig} from '../wysiwyg/config';
import {Component} from './component'; import {Component} from './component';
export class WysiwygEditor extends Component { export class WysiwygEditor extends Component {
setup() { setup() {
this.elem = this.$el; this.elem = this.$el;
this.editContainer = this.$refs.editContainer;
this.input = this.$refs.input;
this.tinyMceConfig = buildEditorConfig({ /** @var {SimpleWysiwygEditorInterface|null} */
language: this.$opts.language, this.editor = null;
containerElement: this.elem,
darkMode: document.documentElement.classList.contains('dark-mode'), const translations = {
textDirection: this.$opts.textDirection, ...window.editor_translations,
drawioUrl: this.getDrawIoUrl(), imageUploadErrorText: this.$opts.imageUploadErrorText,
pageId: Number(this.$opts.pageId), serverUploadLimitText: this.$opts.serverUploadLimitText,
translations: { };
imageUploadErrorText: this.$opts.imageUploadErrorText,
serverUploadLimitText: this.$opts.serverUploadLimitText, window.importVersioned('wysiwyg').then(wysiwyg => {
}, const editorContent = this.input.value;
translationMap: window.editor_translations, this.editor = wysiwyg.createPageEditorInstance(this.editContainer, editorContent, {
drawioUrl: this.getDrawIoUrl(),
pageId: Number(this.$opts.pageId),
darkMode: document.documentElement.classList.contains('dark-mode'),
textDirection: this.$opts.textDirection,
translations,
});
}); });
window.$events.emitPublic(this.elem, 'editor-tinymce::pre-init', {config: this.tinyMceConfig}); let handlingFormSubmit = false;
window.tinymce.init(this.tinyMceConfig).then(editors => { this.input.form.addEventListener('submit', event => {
this.editor = editors[0]; if (!this.editor) {
return;
}
if (!handlingFormSubmit) {
event.preventDefault();
handlingFormSubmit = true;
this.editor.getContentAsHtml().then(html => {
this.input.value = html;
this.input.form.submit();
});
} else {
handlingFormSubmit = false;
}
}); });
} }
@ -37,11 +57,11 @@ export class WysiwygEditor extends Component {
/** /**
* Get the content of this editor. * Get the content of this editor.
* Used by the parent page editor component. * Used by the parent page editor component.
* @return {{html: String}} * @return {Promise<{html: String}>}
*/ */
getContent() { async getContent() {
return { return {
html: this.editor.getContent(), html: await this.editor.getContentAsHtml(),
}; };
} }

View File

@ -1,5 +1,5 @@
import {Component} from './component'; import {Component} from './component';
import {buildForInput} from '../wysiwyg/config'; import {buildForInput} from '../wysiwyg-tinymce/config';
export class WysiwygInput extends Component { export class WysiwygInput extends Component {

4
resources/js/custom.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.svg' {
const content: string;
export default content;
}

14
resources/js/global.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
import {ComponentStore} from "./services/components";
import {EventManager} from "./services/events";
import {HttpManager} from "./services/http";
declare global {
const __DEV__: boolean;
interface Window {
$components: ComponentStore;
$events: EventManager;
$http: HttpManager;
baseUrl: (path: string) => string;
}
}

View File

@ -1,4 +1,4 @@
import * as DrawIO from '../services/drawio'; import * as DrawIO from '../services/drawio.ts';
export class Actions { export class Actions {

View File

@ -1,165 +0,0 @@
import {kebabToCamel, camelToKebab} from './text';
/**
* A mapping of active components keyed by name, with values being arrays of component
* instances since there can be multiple components of the same type.
* @type {Object<String, Component[]>}
*/
const components = {};
/**
* A mapping of component class models, keyed by name.
* @type {Object<String, Constructor<Component>>}
*/
const componentModelMap = {};
/**
* A mapping of active component maps, keyed by the element components are assigned to.
* @type {WeakMap<Element, Object<String, Component>>}
*/
const elementComponentMap = new WeakMap();
/**
* Parse out the element references within the given element
* for the given component name.
* @param {String} name
* @param {Element} element
*/
function parseRefs(name, element) {
const refs = {};
const manyRefs = {};
const prefix = `${name}@`;
const selector = `[refs*="${prefix}"]`;
const refElems = [...element.querySelectorAll(selector)];
if (element.matches(selector)) {
refElems.push(element);
}
for (const el of refElems) {
const refNames = el.getAttribute('refs')
.split(' ')
.filter(str => str.startsWith(prefix))
.map(str => str.replace(prefix, ''))
.map(kebabToCamel);
for (const ref of refNames) {
refs[ref] = el;
if (typeof manyRefs[ref] === 'undefined') {
manyRefs[ref] = [];
}
manyRefs[ref].push(el);
}
}
return {refs, manyRefs};
}
/**
* Parse out the element component options.
* @param {String} componentName
* @param {Element} element
* @return {Object<String, String>}
*/
function parseOpts(componentName, element) {
const opts = {};
const prefix = `option:${componentName}:`;
for (const {name, value} of element.attributes) {
if (name.startsWith(prefix)) {
const optName = name.replace(prefix, '');
opts[kebabToCamel(optName)] = value || '';
}
}
return opts;
}
/**
* Initialize a component instance on the given dom element.
* @param {String} name
* @param {Element} element
*/
function initComponent(name, element) {
/** @type {Function<Component>|undefined} * */
const ComponentModel = componentModelMap[name];
if (ComponentModel === undefined) return;
// Create our component instance
/** @type {Component} * */
let instance;
try {
instance = new ComponentModel();
instance.$name = name;
instance.$el = element;
const allRefs = parseRefs(name, element);
instance.$refs = allRefs.refs;
instance.$manyRefs = allRefs.manyRefs;
instance.$opts = parseOpts(name, element);
instance.setup();
} catch (e) {
console.error('Failed to create component', e, name, element);
}
// Add to global listing
if (typeof components[name] === 'undefined') {
components[name] = [];
}
components[name].push(instance);
// Add to element mapping
const elComponents = elementComponentMap.get(element) || {};
elComponents[name] = instance;
elementComponentMap.set(element, elComponents);
}
/**
* Initialize all components found within the given element.
* @param {Element|Document} parentElement
*/
export function init(parentElement = document) {
const componentElems = parentElement.querySelectorAll('[component],[components]');
for (const el of componentElems) {
const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean);
for (const name of componentNames) {
initComponent(name, el);
}
}
}
/**
* Register the given component mapping into the component system.
* @param {Object<String, ObjectConstructor<Component>>} mapping
*/
export function register(mapping) {
const keys = Object.keys(mapping);
for (const key of keys) {
componentModelMap[camelToKebab(key)] = mapping[key];
}
}
/**
* Get the first component of the given name.
* @param {String} name
* @returns {Component|null}
*/
export function first(name) {
return (components[name] || [null])[0];
}
/**
* Get all the components of the given name.
* @param {String} name
* @returns {Component[]}
*/
export function get(name) {
return components[name] || [];
}
/**
* Get the first component, of the given name, that's assigned to the given element.
* @param {Element} element
* @param {String} name
* @returns {Component|null}
*/
export function firstOnElement(element, name) {
const elComponents = elementComponentMap.get(element) || {};
return elComponents[name] || null;
}

View File

@ -0,0 +1,153 @@
import {kebabToCamel, camelToKebab} from './text';
import {Component} from "../components/component";
/**
* Parse out the element references within the given element
* for the given component name.
*/
function parseRefs(name: string, element: HTMLElement):
{refs: Record<string, HTMLElement>, manyRefs: Record<string, HTMLElement[]>} {
const refs: Record<string, HTMLElement> = {};
const manyRefs: Record<string, HTMLElement[]> = {};
const prefix = `${name}@`;
const selector = `[refs*="${prefix}"]`;
const refElems = [...element.querySelectorAll(selector)];
if (element.matches(selector)) {
refElems.push(element);
}
for (const el of refElems as HTMLElement[]) {
const refNames = (el.getAttribute('refs') || '')
.split(' ')
.filter(str => str.startsWith(prefix))
.map(str => str.replace(prefix, ''))
.map(kebabToCamel);
for (const ref of refNames) {
refs[ref] = el;
if (typeof manyRefs[ref] === 'undefined') {
manyRefs[ref] = [];
}
manyRefs[ref].push(el);
}
}
return {refs, manyRefs};
}
/**
* Parse out the element component options.
*/
function parseOpts(componentName: string, element: HTMLElement): Record<string, string> {
const opts: Record<string, string> = {};
const prefix = `option:${componentName}:`;
for (const {name, value} of element.attributes) {
if (name.startsWith(prefix)) {
const optName = name.replace(prefix, '');
opts[kebabToCamel(optName)] = value || '';
}
}
return opts;
}
export class ComponentStore {
/**
* A mapping of active components keyed by name, with values being arrays of component
* instances since there can be multiple components of the same type.
*/
protected components: Record<string, Component[]> = {};
/**
* A mapping of component class models, keyed by name.
*/
protected componentModelMap: Record<string, typeof Component> = {};
/**
* A mapping of active component maps, keyed by the element components are assigned to.
*/
protected elementComponentMap: WeakMap<HTMLElement, Record<string, Component>> = new WeakMap();
/**
* Initialize a component instance on the given dom element.
*/
protected initComponent(name: string, element: HTMLElement): void {
const ComponentModel = this.componentModelMap[name];
if (ComponentModel === undefined) return;
// Create our component instance
let instance: Component|null = null;
try {
instance = new ComponentModel();
instance.$name = name;
instance.$el = element;
const allRefs = parseRefs(name, element);
instance.$refs = allRefs.refs;
instance.$manyRefs = allRefs.manyRefs;
instance.$opts = parseOpts(name, element);
instance.setup();
} catch (e) {
console.error('Failed to create component', e, name, element);
}
if (!instance) {
return;
}
// Add to global listing
if (typeof this.components[name] === 'undefined') {
this.components[name] = [];
}
this.components[name].push(instance);
// Add to element mapping
const elComponents = this.elementComponentMap.get(element) || {};
elComponents[name] = instance;
this.elementComponentMap.set(element, elComponents);
}
/**
* Initialize all components found within the given element.
*/
public init(parentElement: Document|HTMLElement = document) {
const componentElems = parentElement.querySelectorAll('[component],[components]');
for (const el of componentElems) {
const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean);
for (const name of componentNames) {
this.initComponent(name, el as HTMLElement);
}
}
}
/**
* Register the given component mapping into the component system.
* @param {Object<String, ObjectConstructor<Component>>} mapping
*/
public register(mapping: Record<string, typeof Component>) {
const keys = Object.keys(mapping);
for (const key of keys) {
this.componentModelMap[camelToKebab(key)] = mapping[key];
}
}
/**
* Get the first component of the given name.
*/
public first(name: string): Component|null {
return (this.components[name] || [null])[0];
}
/**
* Get all the components of the given name.
*/
public get(name: string): Component[] {
return this.components[name] || [];
}
/**
* Get the first component, of the given name, that's assigned to the given element.
*/
public firstOnElement(element: HTMLElement, name: string): Component|null {
const elComponents = this.elementComponentMap.get(element) || {};
return elComponents[name] || null;
}
}

View File

@ -1,17 +1,32 @@
// Docs: https://www.diagrams.net/doc/faq/embed-mode // Docs: https://www.diagrams.net/doc/faq/embed-mode
import * as store from './store'; import * as store from './store';
import {ConfirmDialog} from "../components";
import {HttpError} from "./http";
let iFrame = null; type DrawioExportEventResponse = {
let lastApprovedOrigin; action: 'export',
let onInit; format: string,
let onSave; message: string,
data: string,
xml: string,
};
type DrawioSaveEventResponse = {
action: 'save',
xml: string,
};
let iFrame: HTMLIFrameElement|null = null;
let lastApprovedOrigin: string;
let onInit: () => Promise<string>;
let onSave: (data: string) => Promise<any>;
const saveBackupKey = 'last-drawing-save'; const saveBackupKey = 'last-drawing-save';
function drawPostMessage(data) { function drawPostMessage(data: Record<any, any>): void {
iFrame.contentWindow.postMessage(JSON.stringify(data), lastApprovedOrigin); iFrame?.contentWindow?.postMessage(JSON.stringify(data), lastApprovedOrigin);
} }
function drawEventExport(message) { function drawEventExport(message: DrawioExportEventResponse) {
store.set(saveBackupKey, message.data); store.set(saveBackupKey, message.data);
if (onSave) { if (onSave) {
onSave(message.data).then(() => { onSave(message.data).then(() => {
@ -20,7 +35,7 @@ function drawEventExport(message) {
} }
} }
function drawEventSave(message) { function drawEventSave(message: DrawioSaveEventResponse) {
drawPostMessage({ drawPostMessage({
action: 'export', format: 'xmlpng', xml: message.xml, spin: 'Updating drawing', action: 'export', format: 'xmlpng', xml: message.xml, spin: 'Updating drawing',
}); });
@ -35,8 +50,10 @@ function drawEventInit() {
function drawEventConfigure() { function drawEventConfigure() {
const config = {}; const config = {};
window.$events.emitPublic(iFrame, 'editor-drawio::configure', {config}); if (iFrame) {
drawPostMessage({action: 'configure', config}); window.$events.emitPublic(iFrame, 'editor-drawio::configure', {config});
drawPostMessage({action: 'configure', config});
}
} }
function drawEventClose() { function drawEventClose() {
@ -47,9 +64,8 @@ function drawEventClose() {
/** /**
* Receive and handle a message event from the draw.io window. * Receive and handle a message event from the draw.io window.
* @param {MessageEvent} event
*/ */
function drawReceive(event) { function drawReceive(event: MessageEvent) {
if (!event.data || event.data.length < 1) return; if (!event.data || event.data.length < 1) return;
if (event.origin !== lastApprovedOrigin) return; if (event.origin !== lastApprovedOrigin) return;
@ -59,9 +75,9 @@ function drawReceive(event) {
} else if (message.event === 'exit') { } else if (message.event === 'exit') {
drawEventClose(); drawEventClose();
} else if (message.event === 'save') { } else if (message.event === 'save') {
drawEventSave(message); drawEventSave(message as DrawioSaveEventResponse);
} else if (message.event === 'export') { } else if (message.event === 'export') {
drawEventExport(message); drawEventExport(message as DrawioExportEventResponse);
} else if (message.event === 'configure') { } else if (message.event === 'configure') {
drawEventConfigure(); drawEventConfigure();
} }
@ -79,9 +95,8 @@ async function attemptRestoreIfExists() {
console.error('Missing expected unsaved-drawing dialog'); console.error('Missing expected unsaved-drawing dialog');
} }
if (backupVal) { if (backupVal && dialogEl) {
/** @var {ConfirmDialog} */ const dialog = window.$components.firstOnElement(dialogEl, 'confirm-dialog') as ConfirmDialog;
const dialog = window.$components.firstOnElement(dialogEl, 'confirm-dialog');
const restore = await dialog.show(); const restore = await dialog.show();
if (restore) { if (restore) {
onInit = async () => backupVal; onInit = async () => backupVal;
@ -94,11 +109,9 @@ async function attemptRestoreIfExists() {
* onSaveCallback must return a promise that resolves on successful save and errors on failure. * onSaveCallback must return a promise that resolves on successful save and errors on failure.
* onInitCallback must return a promise with the xml to load for the editor. * onInitCallback must return a promise with the xml to load for the editor.
* Will attempt to provide an option to restore unsaved changes if found to exist. * Will attempt to provide an option to restore unsaved changes if found to exist.
* @param {String} drawioUrl * onSaveCallback Is called with the drawing data on save.
* @param {Function<Promise<String>>} onInitCallback
* @param {Function<Promise>} onSaveCallback - Is called with the drawing data on save.
*/ */
export async function show(drawioUrl, onInitCallback, onSaveCallback) { export async function show(drawioUrl: string, onInitCallback: () => Promise<string>, onSaveCallback: (data: string) => Promise<void>): Promise<void> {
onInit = onInitCallback; onInit = onInitCallback;
onSave = onSaveCallback; onSave = onSaveCallback;
@ -114,13 +127,13 @@ export async function show(drawioUrl, onInitCallback, onSaveCallback) {
lastApprovedOrigin = (new URL(drawioUrl)).origin; lastApprovedOrigin = (new URL(drawioUrl)).origin;
} }
export async function upload(imageData, pageUploadedToId) { export async function upload(imageData: string, pageUploadedToId: string): Promise<{id: number, url: string}> {
const data = { const data = {
image: imageData, image: imageData,
uploaded_to: pageUploadedToId, uploaded_to: pageUploadedToId,
}; };
const resp = await window.$http.post(window.baseUrl('/images/drawio'), data); const resp = await window.$http.post(window.baseUrl('/images/drawio'), data);
return resp.data; return resp.data as {id: number, url: string};
} }
export function close() { export function close() {
@ -129,15 +142,14 @@ export function close() {
/** /**
* Load an existing image, by fetching it as Base64 from the system. * Load an existing image, by fetching it as Base64 from the system.
* @param drawingId
* @returns {Promise<string>}
*/ */
export async function load(drawingId) { export async function load(drawingId: string): Promise<string> {
try { try {
const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`)); const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`));
return `data:image/png;base64,${resp.data.content}`; const data = resp.data as {content: string};
return `data:image/png;base64,${data.content}`;
} catch (error) { } catch (error) {
if (error instanceof window.$http.HttpError) { if (error instanceof HttpError) {
window.$events.showResponseError(error); window.$events.showResponseError(error);
} }
close(); close();

View File

@ -1,81 +0,0 @@
const listeners = {};
const stack = [];
/**
* Emit a custom event for any handlers to pick-up.
* @param {String} eventName
* @param {*} eventData
*/
export function emit(eventName, eventData) {
stack.push({name: eventName, data: eventData});
const listenersToRun = listeners[eventName] || [];
for (const listener of listenersToRun) {
listener(eventData);
}
}
/**
* Listen to a custom event and run the given callback when that event occurs.
* @param {String} eventName
* @param {Function} callback
* @returns {Events}
*/
export function listen(eventName, callback) {
if (typeof listeners[eventName] === 'undefined') listeners[eventName] = [];
listeners[eventName].push(callback);
}
/**
* Emit an event for public use.
* Sends the event via the native DOM event handling system.
* @param {Element} targetElement
* @param {String} eventName
* @param {Object} eventData
*/
export function emitPublic(targetElement, eventName, eventData) {
const event = new CustomEvent(eventName, {
detail: eventData,
bubbles: true,
});
targetElement.dispatchEvent(event);
}
/**
* Emit a success event with the provided message.
* @param {String} message
*/
export function success(message) {
emit('success', message);
}
/**
* Emit an error event with the provided message.
* @param {String} message
*/
export function error(message) {
emit('error', message);
}
/**
* Notify of standard server-provided validation errors.
* @param {Object} responseErr
*/
export function showValidationErrors(responseErr) {
if (!responseErr.status) return;
if (responseErr.status === 422 && responseErr.data) {
const message = Object.values(responseErr.data).flat().join('\n');
error(message);
}
}
/**
* Notify standard server-provided error messages.
* @param {Object} responseErr
*/
export function showResponseError(responseErr) {
if (!responseErr.status) return;
if (responseErr.status >= 400 && responseErr.data && responseErr.data.message) {
error(responseErr.data.message);
}
}

View File

@ -0,0 +1,73 @@
import {HttpError} from "./http";
export class EventManager {
protected listeners: Record<string, ((data: any) => void)[]> = {};
protected stack: {name: string, data: {}}[] = [];
/**
* Emit a custom event for any handlers to pick-up.
*/
emit(eventName: string, eventData: {} = {}): void {
this.stack.push({name: eventName, data: eventData});
const listenersToRun = this.listeners[eventName] || [];
for (const listener of listenersToRun) {
listener(eventData);
}
}
/**
* Listen to a custom event and run the given callback when that event occurs.
*/
listen<T>(eventName: string, callback: (data: T) => void): void {
if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = [];
this.listeners[eventName].push(callback);
}
/**
* Emit an event for public use.
* Sends the event via the native DOM event handling system.
*/
emitPublic(targetElement: Element, eventName: string, eventData: {}): void {
const event = new CustomEvent(eventName, {
detail: eventData,
bubbles: true,
});
targetElement.dispatchEvent(event);
}
/**
* Emit a success event with the provided message.
*/
success(message: string): void {
this.emit('success', message);
}
/**
* Emit an error event with the provided message.
*/
error(message: string): void {
this.emit('error', message);
}
/**
* Notify of standard server-provided validation errors.
*/
showValidationErrors(responseErr: {status?: number, data?: object}): void {
if (!responseErr.status) return;
if (responseErr.status === 422 && responseErr.data) {
const message = Object.values(responseErr.data).flat().join('\n');
this.error(message);
}
}
/**
* Notify standard server-provided error messages.
*/
showResponseError(responseErr: {status?: number, data?: Record<any, any>}|HttpError): void {
if (!responseErr.status) return;
if (responseErr.status >= 400 && typeof responseErr.data === 'object' && responseErr.data.message) {
this.error(responseErr.data.message);
}
}
}

View File

@ -1,238 +0,0 @@
/**
* @typedef FormattedResponse
* @property {Headers} headers
* @property {Response} original
* @property {Object|String} data
* @property {Boolean} redirected
* @property {Number} status
* @property {string} statusText
* @property {string} url
*/
/**
* Get the content from a fetch response.
* Checks the content-type header to determine the format.
* @param {Response} response
* @returns {Promise<Object|String>}
*/
async function getResponseContent(response) {
if (response.status === 204) {
return null;
}
const responseContentType = response.headers.get('Content-Type') || '';
const subType = responseContentType.split(';')[0].split('/').pop();
if (subType === 'javascript' || subType === 'json') {
return response.json();
}
return response.text();
}
export class HttpError extends Error {
constructor(response, content) {
super(response.statusText);
this.data = content;
this.headers = response.headers;
this.redirected = response.redirected;
this.status = response.status;
this.statusText = response.statusText;
this.url = response.url;
this.original = response;
}
}
/**
* @param {String} method
* @param {String} url
* @param {Object} events
* @return {XMLHttpRequest}
*/
export function createXMLHttpRequest(method, url, events = {}) {
const csrfToken = document.querySelector('meta[name=token]').getAttribute('content');
const req = new XMLHttpRequest();
for (const [eventName, callback] of Object.entries(events)) {
req.addEventListener(eventName, callback.bind(req));
}
req.open(method, url);
req.withCredentials = true;
req.setRequestHeader('X-CSRF-TOKEN', csrfToken);
return req;
}
/**
* Create a new HTTP request, setting the required CSRF information
* to communicate with the back-end. Parses & formats the response.
* @param {String} url
* @param {Object} options
* @returns {Promise<FormattedResponse>}
*/
async function request(url, options = {}) {
let requestUrl = url;
if (!requestUrl.startsWith('http')) {
requestUrl = window.baseUrl(requestUrl);
}
if (options.params) {
const urlObj = new URL(requestUrl);
for (const paramName of Object.keys(options.params)) {
const value = options.params[paramName];
if (typeof value !== 'undefined' && value !== null) {
urlObj.searchParams.set(paramName, value);
}
}
requestUrl = urlObj.toString();
}
const csrfToken = document.querySelector('meta[name=token]').getAttribute('content');
const requestOptions = {...options, credentials: 'same-origin'};
requestOptions.headers = {
...requestOptions.headers || {},
baseURL: window.baseUrl(''),
'X-CSRF-TOKEN': csrfToken,
};
const response = await fetch(requestUrl, requestOptions);
const content = await getResponseContent(response);
const returnData = {
data: content,
headers: response.headers,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
url: response.url,
original: response,
};
if (!response.ok) {
throw new HttpError(response, content);
}
return returnData;
}
/**
* Perform a HTTP request to the back-end that includes data in the body.
* Parses the body to JSON if an object, setting the correct headers.
* @param {String} method
* @param {String} url
* @param {Object} data
* @returns {Promise<FormattedResponse>}
*/
async function dataRequest(method, url, data = null) {
const options = {
method,
body: data,
};
// Send data as JSON if a plain object
if (typeof data === 'object' && !(data instanceof FormData)) {
options.headers = {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
};
options.body = JSON.stringify(data);
}
// Ensure FormData instances are sent over POST
// Since Laravel does not read multipart/form-data from other types
// of request. Hence the addition of the magic _method value.
if (data instanceof FormData && method !== 'post') {
data.append('_method', method);
options.method = 'post';
}
return request(url, options);
}
/**
* Perform a HTTP GET request.
* Can easily pass query parameters as the second parameter.
* @param {String} url
* @param {Object} params
* @returns {Promise<FormattedResponse>}
*/
export async function get(url, params = {}) {
return request(url, {
method: 'GET',
params,
});
}
/**
* Perform a HTTP POST request.
* @param {String} url
* @param {Object} data
* @returns {Promise<FormattedResponse>}
*/
export async function post(url, data = null) {
return dataRequest('POST', url, data);
}
/**
* Perform a HTTP PUT request.
* @param {String} url
* @param {Object} data
* @returns {Promise<FormattedResponse>}
*/
export async function put(url, data = null) {
return dataRequest('PUT', url, data);
}
/**
* Perform a HTTP PATCH request.
* @param {String} url
* @param {Object} data
* @returns {Promise<FormattedResponse>}
*/
export async function patch(url, data = null) {
return dataRequest('PATCH', url, data);
}
/**
* Perform a HTTP DELETE request.
* @param {String} url
* @param {Object} data
* @returns {Promise<FormattedResponse>}
*/
async function performDelete(url, data = null) {
return dataRequest('DELETE', url, data);
}
export {performDelete as delete};
/**
* Parse the response text for an error response to a user
* presentable string. Handles a range of errors responses including
* validation responses & server response text.
* @param {String} text
* @returns {String}
*/
export function formatErrorResponseText(text) {
const data = text.startsWith('{') ? JSON.parse(text) : {message: text};
if (!data) {
return text;
}
if (data.message || data.error) {
return data.message || data.error;
}
const values = Object.values(data);
const isValidation = values.every(val => {
return Array.isArray(val) || val.every(x => typeof x === 'string');
});
if (isValidation) {
return values.flat().join(' ');
}
return text;
}

View File

@ -0,0 +1,221 @@
type ResponseData = Record<any, any>|string;
type RequestOptions = {
params?: Record<string, string>,
headers?: Record<string, string>
};
type FormattedResponse = {
headers: Headers;
original: Response;
data: ResponseData;
redirected: boolean;
status: number;
statusText: string;
url: string;
};
export class HttpError extends Error implements FormattedResponse {
data: ResponseData;
headers: Headers;
original: Response;
redirected: boolean;
status: number;
statusText: string;
url: string;
constructor(response: Response, content: ResponseData) {
super(response.statusText);
this.data = content;
this.headers = response.headers;
this.redirected = response.redirected;
this.status = response.status;
this.statusText = response.statusText;
this.url = response.url;
this.original = response;
}
}
export class HttpManager {
/**
* Get the content from a fetch response.
* Checks the content-type header to determine the format.
*/
protected async getResponseContent(response: Response): Promise<ResponseData|null> {
if (response.status === 204) {
return null;
}
const responseContentType = response.headers.get('Content-Type') || '';
const subType = responseContentType.split(';')[0].split('/').pop();
if (subType === 'javascript' || subType === 'json') {
return response.json();
}
return response.text();
}
createXMLHttpRequest(method: string, url: string, events: Record<string, (e: Event) => void> = {}): XMLHttpRequest {
const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content');
const req = new XMLHttpRequest();
for (const [eventName, callback] of Object.entries(events)) {
req.addEventListener(eventName, callback.bind(req));
}
req.open(method, url);
req.withCredentials = true;
req.setRequestHeader('X-CSRF-TOKEN', csrfToken || '');
return req;
}
/**
* Create a new HTTP request, setting the required CSRF information
* to communicate with the back-end. Parses & formats the response.
*/
protected async request(url: string, options: RequestOptions & RequestInit = {}): Promise<FormattedResponse> {
let requestUrl = url;
if (!requestUrl.startsWith('http')) {
requestUrl = window.baseUrl(requestUrl);
}
if (options.params) {
const urlObj = new URL(requestUrl);
for (const paramName of Object.keys(options.params)) {
const value = options.params[paramName];
if (typeof value !== 'undefined' && value !== null) {
urlObj.searchParams.set(paramName, value);
}
}
requestUrl = urlObj.toString();
}
const csrfToken = document.querySelector('meta[name=token]')?.getAttribute('content') || '';
const requestOptions: RequestInit = {...options, credentials: 'same-origin'};
requestOptions.headers = {
...requestOptions.headers || {},
baseURL: window.baseUrl(''),
'X-CSRF-TOKEN': csrfToken,
};
const response = await fetch(requestUrl, requestOptions);
const content = await this.getResponseContent(response) || '';
const returnData: FormattedResponse = {
data: content,
headers: response.headers,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
url: response.url,
original: response,
};
if (!response.ok) {
throw new HttpError(response, content);
}
return returnData;
}
/**
* Perform a HTTP request to the back-end that includes data in the body.
* Parses the body to JSON if an object, setting the correct headers.
*/
protected async dataRequest(method: string, url: string, data: Record<string, any>|null): Promise<FormattedResponse> {
const options: RequestInit & RequestOptions = {
method,
body: data as BodyInit,
};
// Send data as JSON if a plain object
if (typeof data === 'object' && !(data instanceof FormData)) {
options.headers = {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
};
options.body = JSON.stringify(data);
}
// Ensure FormData instances are sent over POST
// Since Laravel does not read multipart/form-data from other types
// of request, hence the addition of the magic _method value.
if (data instanceof FormData && method !== 'post') {
data.append('_method', method);
options.method = 'post';
}
return this.request(url, options);
}
/**
* Perform a HTTP GET request.
* Can easily pass query parameters as the second parameter.
*/
async get(url: string, params: {} = {}): Promise<FormattedResponse> {
return this.request(url, {
method: 'GET',
params,
});
}
/**
* Perform a HTTP POST request.
*/
async post(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('POST', url, data);
}
/**
* Perform a HTTP PUT request.
*/
async put(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('PUT', url, data);
}
/**
* Perform a HTTP PATCH request.
*/
async patch(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('PATCH', url, data);
}
/**
* Perform a HTTP DELETE request.
*/
async delete(url: string, data: null|Record<string, any> = null): Promise<FormattedResponse> {
return this.dataRequest('DELETE', url, data);
}
/**
* Parse the response text for an error response to a user
* presentable string. Handles a range of errors responses including
* validation responses & server response text.
*/
protected formatErrorResponseText(text: string): string {
const data = text.startsWith('{') ? JSON.parse(text) : {message: text};
if (!data) {
return text;
}
if (data.message || data.error) {
return data.message || data.error;
}
const values = Object.values(data);
const isValidation = values.every(val => {
return Array.isArray(val) && val.every(x => typeof x === 'string');
});
if (isValidation) {
return values.flat().join(' ');
}
return text;
}
}

View File

@ -1,19 +1,15 @@
/** /**
* Convert a kebab-case string to camelCase * Convert a kebab-case string to camelCase
* @param {String} kebab
* @returns {string}
*/ */
export function kebabToCamel(kebab) { export function kebabToCamel(kebab: string): string {
const ucFirst = word => word.slice(0, 1).toUpperCase() + word.slice(1); const ucFirst = (word: string) => word.slice(0, 1).toUpperCase() + word.slice(1);
const words = kebab.split('-'); const words = kebab.split('-');
return words[0] + words.slice(1).map(ucFirst).join(''); return words[0] + words.slice(1).map(ucFirst).join('');
} }
/** /**
* Convert a camelCase string to a kebab-case string. * Convert a camelCase string to a kebab-case string.
* @param {String} camelStr
* @returns {String}
*/ */
export function camelToKebab(camelStr) { export function camelToKebab(camelStr: string): string {
return camelStr.replace(/[A-Z]/g, (str, offset) => (offset > 0 ? '-' : '') + str.toLowerCase()); return camelStr.replace(/[A-Z]/g, (str, offset) => (offset > 0 ? '-' : '') + str.toLowerCase());
} }

View File

@ -84,6 +84,17 @@ export function uniqueId() {
return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`); return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`);
} }
/**
* Generate a random smaller unique ID.
*
* @returns {string}
*/
export function uniqueIdSmall() {
// eslint-disable-next-line no-bitwise
const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
return S4();
}
/** /**
* Create a promise that resolves after the given time. * Create a promise that resolves after the given time.
* @param {int} timeMs * @param {int} timeMs

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