Compare commits
119 Commits
76e949b74b
...
8b10e991ac
Author | SHA1 | Date | |
---|---|---|---|
|
8b10e991ac | ||
|
97a46bc322 | ||
|
2ba311cb41 | ||
|
f583354748 | ||
|
d12e8ec923 | ||
|
89f84c9a95 | ||
|
6103a22feb | ||
|
42264f402d | ||
|
abda9bc00a | ||
|
eec639d84e | ||
|
56b9107c6b | ||
|
b35b62d59f | ||
|
1b9310e766 | ||
|
a62d8381be | ||
|
8b32e6c15a | ||
|
c8ccb2bac7 | ||
|
ef3de1050f | ||
|
2add15bd72 | ||
|
e6edd9340e | ||
|
654a7a5d03 | ||
|
dba8ab947f | ||
|
787e06e3d8 | ||
|
ccd486f2a9 | ||
|
22d078b47f | ||
|
03490d6597 | ||
|
5f46d71af0 | ||
|
6872eb802c | ||
|
662110c269 | ||
|
5083188ed8 | ||
|
2036438203 | ||
|
ced66f1671 | ||
|
fb49371c6b | ||
|
fd07aa0f05 | ||
|
16518a4f89 | ||
|
bed2c29a33 | ||
|
e5b6d28bca | ||
|
1c9afcb84e | ||
|
1ebb0f8c93 | ||
|
8a13a9df80 | ||
|
ddf5f2543c | ||
|
dbb2fe3e59 | ||
|
aa1fac62d5 | ||
|
111a313d51 | ||
|
0039f893cc | ||
|
ad6b26ba97 | ||
|
1ef4044419 | ||
|
accf2565a0 | ||
|
ec965f28c0 | ||
|
ebf95f637a | ||
|
abbfd42a6c | ||
|
db4208a7eb | ||
|
da54e1d87c | ||
|
e8532ef4de | ||
|
fcc1c2968d | ||
|
b3d3b14f79 | ||
|
8939f310db | ||
|
efec752985 | ||
|
e94ad78ea7 | ||
|
a27a325af7 | ||
|
6b06d490c5 | ||
|
13f8f39dd5 | ||
|
fe05cff64f | ||
|
d86837ac07 | ||
|
9a7edc6e52 | ||
|
ce8c9dd079 | ||
|
c8f6b7e0d6 | ||
|
f284d31861 | ||
|
76b0d2d5d8 | ||
|
2cab778f19 | ||
|
b618287585 | ||
|
63f4b42453 | ||
|
c7c0df0964 | ||
|
fb87fb5750 | ||
|
634b0aaa07 | ||
|
5002a89754 | ||
|
b367490edc | ||
|
ea4c50c2c2 | ||
|
51d8044a54 | ||
|
2c96af9aea | ||
|
04c7e680fd | ||
|
a8f1160743 | ||
|
feca1f0502 | ||
|
d0a5a5ef37 | ||
|
97f570a4ee | ||
|
9ebbf7ce94 | ||
|
c2ecbf071f | ||
|
b1c489090e | ||
|
c9a03c5b01 | ||
|
517c578a5f | ||
|
f10ec3271a | ||
|
4e2820d6e3 | ||
|
72a0e081ca | ||
|
b1130cb1c3 | ||
|
59936631ec | ||
|
3af22ce754 | ||
|
5546b8ff43 | ||
|
a07092b7e6 | ||
|
ac01c62e6e | ||
|
f47f7dd9d2 | ||
|
13d970c7ce | ||
|
e2409a5fab | ||
|
9e43e03db4 | ||
|
a475cf68bf | ||
|
e889bc680b | ||
|
5c343638b6 | ||
|
0722960260 | ||
|
e959c468f6 | ||
|
ba871ec46a | ||
|
a74e04141c | ||
|
7c504a10a8 | ||
|
ae98745439 | ||
|
57259aee00 | ||
|
dc1a40ea74 | ||
|
483d9bf26c | ||
|
b24d60e98d | ||
|
0f8bd869d8 | ||
|
49546cd627 | ||
|
6e852d2e65 | ||
|
5a4f595341 |
@ -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
|
||||||
|
4
.github/workflows/lint-js.yml
vendored
@ -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
@ -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
@ -2,6 +2,7 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/.vscode
|
/.vscode
|
||||||
/composer
|
/composer
|
||||||
|
/coverage
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
.env
|
.env
|
||||||
.idea
|
.idea
|
||||||
|
10
app/Access/UserInviteException.php
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Access;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class UserInviteException extends Exception
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
37
app/Entities/Tools/PageEditorType.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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()}");
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
{
|
{
|
||||||
|
@ -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
@ -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
|
||||||
|
}
|
||||||
|
};
|
@ -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 */',
|
||||||
|
@ -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
@ -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;
|
@ -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',
|
||||||
|
@ -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',
|
||||||
|
@ -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
|
||||||
|
@ -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
17
package.json
@ -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": {
|
||||||
|
@ -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)_
|
||||||
|
1
resources/icons/caret-down-large.svg
Normal 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 |
@ -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 |
1
resources/icons/editor/align-center.svg
Normal 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 |
1
resources/icons/editor/align-justify.svg
Normal 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 |
1
resources/icons/editor/align-left.svg
Normal 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 |
1
resources/icons/editor/align-right.svg
Normal 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 |
1
resources/icons/editor/bold.svg
Normal 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 |
1
resources/icons/editor/code-block.svg
Normal 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 |
1
resources/icons/editor/code.svg
Normal 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 |
1
resources/icons/editor/color-clear.svg
Normal 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 |
1
resources/icons/editor/details.svg
Normal 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 |
1
resources/icons/editor/diagram.svg
Normal 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 |
1
resources/icons/editor/direction-ltr.svg
Normal 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 |
1
resources/icons/editor/direction-rtl.svg
Normal 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 |
1
resources/icons/editor/format-clear.svg
Normal 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 |
1
resources/icons/editor/fullscreen.svg
Normal 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 |
1
resources/icons/editor/help.svg
Normal 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 |
1
resources/icons/editor/highlighter.svg
Normal 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 |
1
resources/icons/editor/horizontal-rule.svg
Normal 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 |
1
resources/icons/editor/image-search.svg
Normal 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 |
1
resources/icons/editor/image.svg
Normal 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 |
1
resources/icons/editor/indent-decrease.svg
Normal 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 |
1
resources/icons/editor/indent-increase.svg
Normal 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 |
1
resources/icons/editor/italic.svg
Normal 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 |
1
resources/icons/editor/link.svg
Normal 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 |
1
resources/icons/editor/list-bullet.svg
Normal 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 |
1
resources/icons/editor/list-check.svg
Normal 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 |
1
resources/icons/editor/list-numbered.svg
Normal 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 |
1
resources/icons/editor/media.svg
Normal 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 |
1
resources/icons/editor/more-horizontal.svg
Normal 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 |
1
resources/icons/editor/redo.svg
Normal 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 |
1
resources/icons/editor/source-view.svg
Normal 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 |
1
resources/icons/editor/strikethrough.svg
Normal 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 |
1
resources/icons/editor/subscript.svg
Normal 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 |
1
resources/icons/editor/superscript.svg
Normal 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 |
1
resources/icons/editor/table-delete-column.svg
Normal 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 |
1
resources/icons/editor/table-delete-row.svg
Normal 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 |
1
resources/icons/editor/table-delete.svg
Normal 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 |
1
resources/icons/editor/table-insert-column-after.svg
Normal 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 |
1
resources/icons/editor/table-insert-column-before.svg
Normal 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 |
1
resources/icons/editor/table-insert-row-above.svg
Normal 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 |
1
resources/icons/editor/table-insert-row-below.svg
Normal 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 |
1
resources/icons/editor/table.svg
Normal 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 |
1
resources/icons/editor/text-color.svg
Normal 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 |
1
resources/icons/editor/underlined.svg
Normal 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 |
1
resources/icons/editor/undo.svg
Normal 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 |
1
resources/icons/editor/unlink.svg
Normal 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 |
@ -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();
|
||||||
|
@ -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';
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
48
resources/js/components/wysiwyg-editor-tinymce.js
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.svg' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
14
resources/js/global.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import * as DrawIO from '../services/drawio';
|
import * as DrawIO from '../services/drawio.ts';
|
||||||
|
|
||||||
export class Actions {
|
export class Actions {
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
|
153
resources/js/services/components.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
73
resources/js/services/events.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
221
resources/js/services/http.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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());
|
||||||
}
|
}
|
@ -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
|
||||||
|