diff --git a/resources/js/wysiwyg/helpers.ts b/resources/js/wysiwyg/helpers.ts new file mode 100644 index 000000000..720f3c6d5 --- /dev/null +++ b/resources/js/wysiwyg/helpers.ts @@ -0,0 +1,36 @@ +import {$createParagraphNode, $getSelection, BaseSelection, LexicalEditor} from "lexical"; +import {LexicalElementNodeCreator, LexicalNodeMatcher} from "./nodes"; +import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; +import {$setBlocksType} from "@lexical/selection"; + +export function selectionContainsNodeType(selection: BaseSelection|null, matcher: LexicalNodeMatcher): boolean { + if (!selection) { + return false; + } + + for (const node of selection.getNodes()) { + if (matcher(node)) { + return true; + } + + for (const parent of node.getParents()) { + if (matcher(parent)) { + return true; + } + } + } + + return false; +} + +export function toggleSelectionBlockNodeType(editor: LexicalEditor, matcher: LexicalNodeMatcher, creator: LexicalElementNodeCreator) { + editor.update(() => { + const selection = $getSelection(); + const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; + if (selection && matcher(blockElement)) { + $setBlocksType(selection, $createParagraphNode); + } else { + $setBlocksType(selection, creator); + } + }); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 9553fd4dd..0dcbf27f5 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -1,18 +1,10 @@ -import { - $createParagraphNode, - $getRoot, - $getSelection, - COMMAND_PRIORITY_LOW, - createCommand, - createEditor, CreateEditorArgs, -} from 'lexical'; +import {$getRoot, createEditor, CreateEditorArgs} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; -import {$getNearestBlockElementAncestorOrThrow, mergeRegister} from '@lexical/utils'; +import {mergeRegister} from '@lexical/utils'; import {$generateNodesFromDOM} from '@lexical/html'; -import {$setBlocksType} from '@lexical/selection'; import {getNodesForPageEditor} from './nodes'; -import {$createCalloutNode, $isCalloutNode, CalloutCategory} from './nodes/callout'; +import {buildEditorUI} from "./ui"; export function createPageEditorInstance(editArea: HTMLElement) { const config: CreateEditorArgs = { @@ -42,25 +34,29 @@ export function createPageEditorInstance(editArea: HTMLElement) { const debugView = document.getElementById('lexical-debug'); editor.registerUpdateListener(({editorState}) => { console.log('editorState', editorState.toJSON()); - debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2); + if (debugView) { + debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2); + } }); + buildEditorUI(editArea, editor); + // Example of creating, registering and using a custom command - const SET_BLOCK_CALLOUT_COMMAND = createCommand(); - editor.registerCommand(SET_BLOCK_CALLOUT_COMMAND, (category: CalloutCategory = 'info') => { - const selection = $getSelection(); - const blockElement = $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]); - if ($isCalloutNode(blockElement)) { - $setBlocksType(selection, $createParagraphNode); - } else { - $setBlocksType(selection, () => $createCalloutNode(category)); - } - return true; - }, COMMAND_PRIORITY_LOW); - - const button = document.getElementById('lexical-button'); - button.addEventListener('click', event => { - editor.dispatchCommand(SET_BLOCK_CALLOUT_COMMAND, 'info'); - }); + // const SET_BLOCK_CALLOUT_COMMAND = createCommand(); + // editor.registerCommand(SET_BLOCK_CALLOUT_COMMAND, (category: CalloutCategory = 'info') => { + // const selection = $getSelection(); + // const blockElement = $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]); + // if ($isCalloutNode(blockElement)) { + // $setBlocksType(selection, $createParagraphNode); + // } else { + // $setBlocksType(selection, () => $createCalloutNode(category)); + // } + // return true; + // }, COMMAND_PRIORITY_LOW); + // + // const button = document.getElementById('lexical-button'); + // button.addEventListener('click', event => { + // editor.dispatchCommand(SET_BLOCK_CALLOUT_COMMAND, 'info'); + // }); } diff --git a/resources/js/wysiwyg/nodes/callout.ts b/resources/js/wysiwyg/nodes/callout.ts index 89b9b162e..e39dcc3ee 100644 --- a/resources/js/wysiwyg/nodes/callout.ts +++ b/resources/js/wysiwyg/nodes/callout.ts @@ -33,6 +33,16 @@ export class CalloutNode extends ElementNode { this.__category = category; } + setCategory(category: CalloutCategory) { + const self = this.getWritable(); + self.__category = category; + } + + getCategory(): CalloutCategory { + const self = this.getLatest(); + return self.__category; + } + createDOM(_config: EditorConfig, _editor: LexicalEditor) { const element = document.createElement('p'); element.classList.add('callout', this.__category || ''); @@ -112,3 +122,7 @@ export function $createCalloutNode(category: CalloutCategory = 'info') { export function $isCalloutNode(node: LexicalNode | null | undefined) { return node instanceof CalloutNode; } + +export function $isCalloutNodeOfCategory(node: LexicalNode | null | undefined, category: CalloutCategory = 'info') { + return node instanceof CalloutNode && (node as CalloutNode).getCategory() === category; +} diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 7dda30647..ffe1b027f 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -1,6 +1,6 @@ import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {CalloutNode} from './callout'; -import {KlassConstructor, LexicalNode, LexicalNodeReplacement, ParagraphNode} from "lexical"; +import {ElementNode, KlassConstructor, LexicalNode, LexicalNodeReplacement, ParagraphNode} from "lexical"; import {CustomParagraphNode} from "./custom-paragraph"; /** @@ -20,3 +20,6 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | } ]; } + +export type LexicalNodeMatcher = (node: LexicalNode|null|undefined) => boolean; +export type LexicalElementNodeCreator = () => ElementNode; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/editor-button.ts b/resources/js/wysiwyg/ui/editor-button.ts new file mode 100644 index 000000000..2ce272fce --- /dev/null +++ b/resources/js/wysiwyg/ui/editor-button.ts @@ -0,0 +1,45 @@ +import {BaseSelection, LexicalEditor} from "lexical"; + +export interface EditorButtonDefinition { + label: string; + action: (editor: LexicalEditor) => void; + isActive: (selection: BaseSelection|null) => boolean; +} + +export class EditorButton { + #definition: EditorButtonDefinition; + #editor: LexicalEditor; + #dom: HTMLButtonElement; + + constructor(definition: EditorButtonDefinition, editor: LexicalEditor) { + this.#definition = definition; + this.#editor = editor; + this.#dom = this.buildDOM(); + } + + private buildDOM(): HTMLButtonElement { + const button = document.createElement("button"); + button.setAttribute('type', 'button'); + button.textContent = this.#definition.label; + button.classList.add('editor-toolbar-button'); + + button.addEventListener('click', event => { + this.runAction(); + }); + + return button; + } + + getDOMElement(): HTMLButtonElement { + return this.#dom; + } + + runAction() { + this.#definition.action(this.#editor); + } + + updateActiveState(selection: BaseSelection|null) { + const isActive = this.#definition.isActive(selection); + this.#dom.classList.toggle('editor-toolbar-button-active', isActive); + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts new file mode 100644 index 000000000..10eaa558f --- /dev/null +++ b/resources/js/wysiwyg/ui/index.ts @@ -0,0 +1,51 @@ +import { + $getSelection, + BaseSelection, + COMMAND_PRIORITY_LOW, + LexicalEditor, + SELECTION_CHANGE_COMMAND +} from "lexical"; +import {$createCalloutNode, $isCalloutNodeOfCategory} from "../nodes/callout"; +import {selectionContainsNodeType, toggleSelectionBlockNodeType} from "../helpers"; +import {EditorButton, EditorButtonDefinition} from "./editor-button"; + +const calloutButton: EditorButtonDefinition = { + label: 'Info Callout', + action(editor: LexicalEditor) { + toggleSelectionBlockNodeType( + editor, + (node) => $isCalloutNodeOfCategory(node, 'info'), + () => $createCalloutNode('info'), + ) + }, + isActive(selection: BaseSelection|null): boolean { + return selectionContainsNodeType(selection, (node) => $isCalloutNodeOfCategory(node, 'info')); + } +} + +const toolbarButtonDefinitions: EditorButtonDefinition[] = [ + calloutButton, +]; + +export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { + const toolbarContainer = document.createElement('div'); + toolbarContainer.classList.add('editor-toolbar-container'); + + const buttons = toolbarButtonDefinitions.map(definition => { + return new EditorButton(definition, editor); + }); + + const buttonElements = buttons.map(button => button.getDOMElement()); + + toolbarContainer.append(...buttonElements); + element.before(toolbarContainer); + + // Update button states on editor selection change + editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { + const selection = $getSelection(); + for (const button of buttons) { + button.updateActiveState(selection); + } + return false; + }, COMMAND_PRIORITY_LOW); +} \ No newline at end of file diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index bbe76090c..90e42e576 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -6,9 +6,11 @@ option:wysiwyg-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}" class=""> - <div> - <button type="button" id="lexical-button">Callout</button> - </div> + <style> + .editor-toolbar-button-active { + background-color: tomato; + } + </style> <div refs="wysiwyg-editor@edit-area" contenteditable="true"> <p id="Content!">Some <strong>content</strong> here</p> diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..e075f973c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,109 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}