Compare commits
114 Commits
e72a264892
...
64795a5886
Author | SHA1 | Date | |
---|---|---|---|
|
64795a5886 | ||
|
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}"
|
||||
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.
|
||||
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
|
||||
# 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:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NPM deps
|
||||
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
|
||||
/.vscode
|
||||
/composer
|
||||
/coverage
|
||||
Homestead.yaml
|
||||
.env
|
||||
.idea
|
||||
|
@ -29,6 +29,10 @@ return [
|
||||
// Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
|
||||
'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.
|
||||
'snappy' => [
|
||||
'pdf_binary' => env('WKHTMLTOPDF', false),
|
||||
|
@ -3,6 +3,7 @@
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorType;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
@ -12,6 +12,7 @@ use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorData;
|
||||
use BookStack\Entities\Tools\PageEditorType;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
@ -127,7 +128,9 @@ class PageRepo
|
||||
}
|
||||
|
||||
$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;
|
||||
|
||||
$haveInput = isset($input['markdown']) || isset($input['html']);
|
||||
@ -136,15 +139,15 @@ class PageRepo
|
||||
if ($haveInput && $inputEmpty) {
|
||||
$pageContent->setNewHTML('', user());
|
||||
} elseif (!empty($input['markdown']) && is_string($input['markdown'])) {
|
||||
$newEditor = 'markdown';
|
||||
$newEditor = PageEditorType::Markdown;
|
||||
$pageContent->setNewMarkdown($input['markdown'], user());
|
||||
} elseif (isset($input['html'])) {
|
||||
$newEditor = 'wysiwyg';
|
||||
$newEditor = ($inputEditor->isHtmlBased() ? $inputEditor : null) ?? ($defaultEditor->isHtmlBased() ? $defaultEditor : null) ?? PageEditorType::WysiwygTinymce;
|
||||
$pageContent->setNewHTML($input['html'], user());
|
||||
}
|
||||
|
||||
if ($newEditor !== $currentEditor && userCan('editor-change')) {
|
||||
$page->editor = $newEditor;
|
||||
$page->editor = $newEditor->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);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@ -94,24 +94,16 @@ class PageEditorData
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
$requestedType = explode('-', $this->requestedEditor)[0];
|
||||
if (($requestedType === 'markdown' || $requestedType === 'wysiwyg') && userCan('editor-change')) {
|
||||
$requestedType = PageEditorType::fromRequestValue($this->requestedEditor);
|
||||
if ($requestedType && userCan('editor-change')) {
|
||||
$editorType = $requestedType;
|
||||
}
|
||||
|
||||
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 Knp\Snappy\Pdf as SnappyPdf;
|
||||
use Dompdf\Dompdf;
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class PdfGenerator
|
||||
@ -85,9 +86,15 @@ class PdfGenerator
|
||||
|
||||
file_put_contents($inputHtml, $html);
|
||||
|
||||
$timeout = intval(config('exports.pdf_command_timeout'));
|
||||
$process = Process::fromShellCommandline($command);
|
||||
$process->setTimeout(15);
|
||||
$process->setTimeout($timeout);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (ProcessTimedOutException $e) {
|
||||
throw new PdfExportException("PDF Export via command failed due to timeout at {$timeout} second(s)");
|
||||
}
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}");
|
||||
|
@ -16,9 +16,9 @@
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-xml": "*",
|
||||
"bacon/bacon-qr-code": "^2.0",
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"doctrine/dbal": "^3.5",
|
||||
"dompdf/dompdf": "^2.0",
|
||||
"dompdf/dompdf": "^3.0",
|
||||
"guzzlehttp/guzzle": "^7.4",
|
||||
"intervention/image": "^3.5",
|
||||
"knplabs/knp-snappy": "^1.5",
|
||||
|
801
composer.lock
generated
@ -14,6 +14,7 @@ const entryPoints = {
|
||||
code: path.join(__dirname, '../../resources/js/code/index.mjs'),
|
||||
'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
|
||||
markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'),
|
||||
wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
|
||||
};
|
||||
|
||||
// Locate our output directory
|
||||
@ -31,6 +32,15 @@ esbuild.build({
|
||||
format: 'esm',
|
||||
minify: isProd,
|
||||
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: {
|
||||
js: '// 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
|
||||
License: MIT
|
||||
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
|
||||
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
|
||||
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
|
||||
License: MIT
|
||||
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_stable' => '(Stable Content)',
|
||||
'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_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made',
|
||||
'pages_edit_enter_changelog' => 'Enter Changelog',
|
||||
|
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:production": "sass ./resources/sass:./public/dist -s compressed",
|
||||
"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": "npm-run-all --parallel build:*:dev",
|
||||
"production": "npm-run-all --parallel build:*:production",
|
||||
@ -14,7 +14,9 @@
|
||||
"livereload": "livereload ./public/dist/",
|
||||
"permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads",
|
||||
"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": {
|
||||
"@lezer/generator": "^1.5.1",
|
||||
@ -23,9 +25,14 @@
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"livereload": "^0.9.3",
|
||||
"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": {
|
||||
"@codemirror/commands": "^6.3.2",
|
||||
@ -44,6 +51,7 @@
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@ssddanbrown/codemirror-lang-smarty": "^1.0.0",
|
||||
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"codemirror": "^6.0.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
@ -59,7 +67,8 @@
|
||||
},
|
||||
"extends": "airbnb-base",
|
||||
"ignorePatterns": [
|
||||
"resources/**/*-stub.js"
|
||||
"resources/**/*-stub.js",
|
||||
"resources/**/*.ts"
|
||||
],
|
||||
"overrides": [],
|
||||
"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)_
|
||||
* [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)_
|
||||
* [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)_
|
||||
|
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 * as httpInstance from './services/http';
|
||||
import {EventManager} from './services/events.ts';
|
||||
import {HttpManager} from './services/http.ts';
|
||||
import Translations from './services/translations';
|
||||
|
||||
import * as components from './services/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
|
||||
window.baseUrl = function baseUrl(path) {
|
||||
@ -21,8 +23,8 @@ window.importVersioned = function importVersioned(moduleName) {
|
||||
};
|
||||
|
||||
// Set events and http services on window
|
||||
window.$http = httpInstance;
|
||||
window.$events = events;
|
||||
window.$http = new HttpManager();
|
||||
window.$events = new EventManager();
|
||||
|
||||
// Translation setup
|
||||
// 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);
|
||||
|
||||
// Load & initialise components
|
||||
components.register(componentMap);
|
||||
window.$components = components;
|
||||
components.init();
|
||||
window.$components = new ComponentStore();
|
||||
window.$components.register(componentMap);
|
||||
window.$components.init();
|
||||
|
@ -58,4 +58,5 @@ export {TriLayout} from './tri-layout';
|
||||
export {UserSelect} from './user-select';
|
||||
export {WebhookEvents} from './webhook-events';
|
||||
export {WysiwygEditor} from './wysiwyg-editor';
|
||||
export {WysiwygEditorTinymce} from './wysiwyg-editor-tinymce';
|
||||
export {WysiwygInput} from './wysiwyg-input';
|
||||
|
@ -133,9 +133,9 @@ export class MarkdownEditor extends Component {
|
||||
/**
|
||||
* Get the content of this editor.
|
||||
* 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();
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {Component} from './component';
|
||||
import {getLoading, htmlToDom} from '../services/dom';
|
||||
import {buildForInput} from '../wysiwyg/config';
|
||||
import {buildForInput} from '../wysiwyg-tinymce/config';
|
||||
|
||||
export class PageComment extends Component {
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {Component} from './component';
|
||||
import {getLoading, htmlToDom} from '../services/dom';
|
||||
import {buildForInput} from '../wysiwyg/config';
|
||||
import {buildForInput} from '../wysiwyg-tinymce/config';
|
||||
|
||||
export class PageComments extends Component {
|
||||
|
||||
|
@ -118,7 +118,7 @@ export class PageEditor extends Component {
|
||||
async saveDraft() {
|
||||
const data = {name: this.titleElem.value.trim()};
|
||||
|
||||
const editorContent = this.getEditorComponent().getContent();
|
||||
const editorContent = await this.getEditorComponent().getContent();
|
||||
Object.assign(data, editorContent);
|
||||
|
||||
let didSave = false;
|
||||
@ -235,10 +235,12 @@ export class PageEditor extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MarkdownEditor|WysiwygEditor
|
||||
* @return {MarkdownEditor|WysiwygEditor|WysiwygEditorTinymce}
|
||||
*/
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
|
||||
|
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';
|
||||
|
||||
export class WysiwygEditor extends Component {
|
||||
|
||||
setup() {
|
||||
this.elem = this.$el;
|
||||
this.editContainer = this.$refs.editContainer;
|
||||
this.input = this.$refs.input;
|
||||
|
||||
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: {
|
||||
/** @var {SimpleWysiwygEditorInterface|null} */
|
||||
this.editor = null;
|
||||
|
||||
const translations = {
|
||||
...window.editor_translations,
|
||||
imageUploadErrorText: this.$opts.imageUploadErrorText,
|
||||
serverUploadLimitText: this.$opts.serverUploadLimitText,
|
||||
},
|
||||
translationMap: window.editor_translations,
|
||||
};
|
||||
|
||||
window.importVersioned('wysiwyg').then(wysiwyg => {
|
||||
const editorContent = this.input.value;
|
||||
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});
|
||||
window.tinymce.init(this.tinyMceConfig).then(editors => {
|
||||
this.editor = editors[0];
|
||||
let handlingFormSubmit = false;
|
||||
this.input.form.addEventListener('submit', event => {
|
||||
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.
|
||||
* Used by the parent page editor component.
|
||||
* @return {{html: String}}
|
||||
* @return {Promise<{html: String}>}
|
||||
*/
|
||||
getContent() {
|
||||
async getContent() {
|
||||
return {
|
||||
html: this.editor.getContent(),
|
||||
html: await this.editor.getContentAsHtml(),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Component} from './component';
|
||||
import {buildForInput} from '../wysiwyg/config';
|
||||
import {buildForInput} from '../wysiwyg-tinymce/config';
|
||||
|
||||
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 {
|
||||
|
||||
|
@ -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
|
||||
import * as store from './store';
|
||||
import {ConfirmDialog} from "../components";
|
||||
import {HttpError} from "./http";
|
||||
|
||||
let iFrame = null;
|
||||
let lastApprovedOrigin;
|
||||
let onInit;
|
||||
let onSave;
|
||||
type DrawioExportEventResponse = {
|
||||
action: 'export',
|
||||
format: string,
|
||||
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';
|
||||
|
||||
function drawPostMessage(data) {
|
||||
iFrame.contentWindow.postMessage(JSON.stringify(data), lastApprovedOrigin);
|
||||
function drawPostMessage(data: Record<any, any>): void {
|
||||
iFrame?.contentWindow?.postMessage(JSON.stringify(data), lastApprovedOrigin);
|
||||
}
|
||||
|
||||
function drawEventExport(message) {
|
||||
function drawEventExport(message: DrawioExportEventResponse) {
|
||||
store.set(saveBackupKey, message.data);
|
||||
if (onSave) {
|
||||
onSave(message.data).then(() => {
|
||||
@ -20,7 +35,7 @@ function drawEventExport(message) {
|
||||
}
|
||||
}
|
||||
|
||||
function drawEventSave(message) {
|
||||
function drawEventSave(message: DrawioSaveEventResponse) {
|
||||
drawPostMessage({
|
||||
action: 'export', format: 'xmlpng', xml: message.xml, spin: 'Updating drawing',
|
||||
});
|
||||
@ -35,9 +50,11 @@ function drawEventInit() {
|
||||
|
||||
function drawEventConfigure() {
|
||||
const config = {};
|
||||
if (iFrame) {
|
||||
window.$events.emitPublic(iFrame, 'editor-drawio::configure', {config});
|
||||
drawPostMessage({action: 'configure', config});
|
||||
}
|
||||
}
|
||||
|
||||
function drawEventClose() {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
@ -47,9 +64,8 @@ function drawEventClose() {
|
||||
|
||||
/**
|
||||
* 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.origin !== lastApprovedOrigin) return;
|
||||
|
||||
@ -59,9 +75,9 @@ function drawReceive(event) {
|
||||
} else if (message.event === 'exit') {
|
||||
drawEventClose();
|
||||
} else if (message.event === 'save') {
|
||||
drawEventSave(message);
|
||||
drawEventSave(message as DrawioSaveEventResponse);
|
||||
} else if (message.event === 'export') {
|
||||
drawEventExport(message);
|
||||
drawEventExport(message as DrawioExportEventResponse);
|
||||
} else if (message.event === 'configure') {
|
||||
drawEventConfigure();
|
||||
}
|
||||
@ -79,9 +95,8 @@ async function attemptRestoreIfExists() {
|
||||
console.error('Missing expected unsaved-drawing dialog');
|
||||
}
|
||||
|
||||
if (backupVal) {
|
||||
/** @var {ConfirmDialog} */
|
||||
const dialog = window.$components.firstOnElement(dialogEl, 'confirm-dialog');
|
||||
if (backupVal && dialogEl) {
|
||||
const dialog = window.$components.firstOnElement(dialogEl, 'confirm-dialog') as ConfirmDialog;
|
||||
const restore = await dialog.show();
|
||||
if (restore) {
|
||||
onInit = async () => backupVal;
|
||||
@ -94,11 +109,9 @@ async function attemptRestoreIfExists() {
|
||||
* 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.
|
||||
* Will attempt to provide an option to restore unsaved changes if found to exist.
|
||||
* @param {String} drawioUrl
|
||||
* @param {Function<Promise<String>>} onInitCallback
|
||||
* @param {Function<Promise>} onSaveCallback - Is called with the drawing data on save.
|
||||
* 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;
|
||||
onSave = onSaveCallback;
|
||||
|
||||
@ -114,13 +127,13 @@ export async function show(drawioUrl, onInitCallback, onSaveCallback) {
|
||||
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 = {
|
||||
image: imageData,
|
||||
uploaded_to: pageUploadedToId,
|
||||
};
|
||||
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() {
|
||||
@ -129,15 +142,14 @@ export function close() {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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) {
|
||||
if (error instanceof window.$http.HttpError) {
|
||||
if (error instanceof HttpError) {
|
||||
window.$events.showResponseError(error);
|
||||
}
|
||||
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
|
||||
* @param {String} kebab
|
||||
* @returns {string}
|
||||
*/
|
||||
export function kebabToCamel(kebab) {
|
||||
const ucFirst = word => word.slice(0, 1).toUpperCase() + word.slice(1);
|
||||
export function kebabToCamel(kebab: string): string {
|
||||
const ucFirst = (word: string) => word.slice(0, 1).toUpperCase() + word.slice(1);
|
||||
const words = kebab.split('-');
|
||||
return words[0] + words.slice(1).map(ucFirst).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
@ -84,6 +84,17 @@ export function uniqueId() {
|
||||
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.
|
||||
* @param {int} timeMs
|
||||
|
@ -1,4 +1,4 @@
|
||||
import * as DrawIO from '../services/drawio';
|
||||
import * as DrawIO from '../services/drawio.ts';
|
||||
import {wait} from '../services/util';
|
||||
|
||||
let pageEditor = null;
|