From a27a325af77e31a184cdb33dc05cb658de697e0b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 2 Aug 2024 15:28:54 +0100 Subject: [PATCH] Lexical: Started on table actions Started building table cell form/actions --- resources/js/wysiwyg/index.ts | 2 + resources/js/wysiwyg/nodes/custom-table.ts | 2 +- resources/js/wysiwyg/todo.md | 8 +- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 62 +++++++++- .../js/wysiwyg/ui/defaults/forms/controls.ts | 18 +++ .../{form-definitions.ts => forms/objects.ts} | 103 +++++++--------- .../js/wysiwyg/ui/defaults/forms/tables.ts | 112 ++++++++++++++++++ resources/js/wysiwyg/ui/defaults/modals.ts | 27 +++++ .../helpers/table-selection-handler.ts | 80 +++++++++++++ resources/js/wysiwyg/ui/index.ts | 21 +--- resources/js/wysiwyg/ui/toolbars.ts | 8 +- 11 files changed, 361 insertions(+), 82 deletions(-) create mode 100644 resources/js/wysiwyg/ui/defaults/forms/controls.ts rename resources/js/wysiwyg/ui/defaults/{form-definitions.ts => forms/objects.ts} (87%) create mode 100644 resources/js/wysiwyg/ui/defaults/forms/tables.ts create mode 100644 resources/js/wysiwyg/ui/defaults/modals.ts create mode 100644 resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 1e9dd25df..71a007f59 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -11,6 +11,7 @@ import {EditorUiContext} from "./ui/framework/core"; import {listen as listenToCommonEvents} from "./common-events"; import {handleDropEvents} from "./drop-handling"; import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler"; +import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -48,6 +49,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), registerTableResizer(editor, editWrap), + registerTableSelectionHandler(editor), registerTaskListHandler(editor, editArea), ); diff --git a/resources/js/wysiwyg/nodes/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts index 1107f0a90..7dda24a7a 100644 --- a/resources/js/wysiwyg/nodes/custom-table.ts +++ b/resources/js/wysiwyg/nodes/custom-table.ts @@ -157,7 +157,7 @@ export function $createCustomTableNode(): CustomTableNode { return new CustomTableNode(); } -export function $isCustomTableNode(node: LexicalNode | null | undefined): boolean { +export function $isCustomTableNode(node: LexicalNode | null | undefined): node is CustomTableNode { return node instanceof CustomTableNode; } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 1a367d0dd..0354b7935 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -3,7 +3,9 @@ ## In progress - Table features - - Continued table dropdown menu + - Continued table dropdown menu + - Connect up cell properties form + - Merge cell action ## Main Todo @@ -21,6 +23,10 @@ - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) - Media resize support (like images) +## Secondary Todo + +- Color picker support in table form color fields + ## Bugs - Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node. diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index b6b92e197..e3f7bb570 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -18,8 +18,8 @@ import { $deleteTableColumn__EXPERIMENTAL, $deleteTableRow__EXPERIMENTAL, $insertTableColumn__EXPERIMENTAL, - $insertTableRow__EXPERIMENTAL, - $isTableNode, + $insertTableRow__EXPERIMENTAL, $isTableCellNode, + $isTableNode, $isTableSelection, $unmergeCell, TableCellNode, } from "@lexical/table"; @@ -128,4 +128,62 @@ export const deleteColumn: EditorButtonDefinition = { isActive() { return false; } +}; + +export const cellProperties: EditorButtonDefinition = { + label: 'Cell properties', + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); + if ($isTableCellNode(cell)) { + + const modalForm = context.manager.createModal('cell_properties'); + modalForm.show({}); + } + }); + }, + isActive() { + return false; + }, + isDisabled(selection) { + return !$selectionContainsNodeType(selection, $isTableCellNode); + } +}; + +export const mergeCells: EditorButtonDefinition = { + label: 'Merge cells', + action(context: EditorUiContext) { + context.editor.update(() => { + // Todo - Needs to be done manually + // Playground reference: + // https://github.com/facebook/lexical/blob/f373759a7849f473d34960a6bf4e34b2a011e762/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx#L299 + }); + }, + isActive() { + return false; + }, + isDisabled(selection) { + return !$isTableSelection(selection); + } +}; + +export const splitCell: EditorButtonDefinition = { + label: 'Split cell', + action(context: EditorUiContext) { + context.editor.update(() => { + $unmergeCell(); + }); + }, + isActive() { + return false; + }, + isDisabled(selection) { + const cell = $getNodeFromSelection(selection, $isTableCellNode) as TableCellNode|null; + if (cell) { + const merged = cell.getRowSpan() > 1 || cell.getColSpan() > 1; + return !merged; + } + + return true; + } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/controls.ts b/resources/js/wysiwyg/ui/defaults/forms/controls.ts new file mode 100644 index 000000000..bcb2f5bad --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/forms/controls.ts @@ -0,0 +1,18 @@ +import {EditorFormDefinition} from "../../framework/forms"; +import {EditorUiContext} from "../../framework/core"; +import {setEditorContentFromHtml} from "../../../actions"; + +export const source: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || ''); + return true; + }, + fields: [ + { + label: 'Source', + name: 'source', + type: 'textarea', + }, + ], +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/form-definitions.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts similarity index 87% rename from resources/js/wysiwyg/ui/defaults/form-definitions.ts rename to resources/js/wysiwyg/ui/defaults/forms/objects.ts index 6c0a54f23..7a388751b 100644 --- a/resources/js/wysiwyg/ui/defaults/form-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -1,13 +1,49 @@ -import {EditorFormDefinition, EditorFormTabs, EditorSelectFormFieldDefinition} from "../framework/forms"; -import {EditorUiContext} from "../framework/core"; +import {EditorFormDefinition, EditorFormTabs, EditorSelectFormFieldDefinition} from "../../framework/forms"; +import {EditorUiContext} from "../../framework/core"; +import {$createTextNode, $getSelection} from "lexical"; +import {$createImageNode} from "../../../nodes/image"; import {$createLinkNode} from "@lexical/link"; -import {$createTextNode, $getSelection, LexicalNode} from "lexical"; -import {$createImageNode} from "../../nodes/image"; -import {setEditorContentFromHtml} from "../../actions"; -import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../nodes/media"; -import {$getNodeFromSelection} from "../../helpers"; +import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; +import {$getNodeFromSelection} from "../../../helpers"; import {$insertNodeToNearestRoot} from "@lexical/utils"; +export const image: EditorFormDefinition = { + submitText: 'Apply', + async action(formData, context: EditorUiContext) { + context.editor.update(() => { + const selection = $getSelection(); + const imageNode = $createImageNode(formData.get('src')?.toString() || '', { + alt: formData.get('alt')?.toString() || '', + height: Number(formData.get('height')?.toString() || '0'), + width: Number(formData.get('width')?.toString() || '0'), + }); + selection?.insertNodes([imageNode]); + }); + return true; + }, + fields: [ + { + label: 'Source', + name: 'src', + type: 'text', + }, + { + label: 'Alternative description', + name: 'alt', + type: 'text', + }, + { + label: 'Width', + name: 'width', + type: 'text', + }, + { + label: 'Height', + name: 'height', + type: 'text', + }, + ], +}; export const link: EditorFormDefinition = { submitText: 'Apply', @@ -54,44 +90,6 @@ export const link: EditorFormDefinition = { ], }; -export const image: EditorFormDefinition = { - submitText: 'Apply', - async action(formData, context: EditorUiContext) { - context.editor.update(() => { - const selection = $getSelection(); - const imageNode = $createImageNode(formData.get('src')?.toString() || '', { - alt: formData.get('alt')?.toString() || '', - height: Number(formData.get('height')?.toString() || '0'), - width: Number(formData.get('width')?.toString() || '0'), - }); - selection?.insertNodes([imageNode]); - }); - return true; - }, - fields: [ - { - label: 'Source', - name: 'src', - type: 'text', - }, - { - label: 'Alternative description', - name: 'alt', - type: 'text', - }, - { - label: 'Width', - name: 'width', - type: 'text', - }, - { - label: 'Height', - name: 'height', - type: 'text', - }, - ], -}; - export const media: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { @@ -169,19 +167,4 @@ export const media: EditorFormDefinition = { } }, ], -}; - -export const source: EditorFormDefinition = { - submitText: 'Save', - async action(formData, context: EditorUiContext) { - setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || ''); - return true; - }, - fields: [ - { - label: 'Source', - name: 'source', - type: 'textarea', - }, - ], }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts new file mode 100644 index 000000000..a045ba55d --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -0,0 +1,112 @@ +import { + EditorFormDefinition, + EditorFormFieldDefinition, + EditorFormTabs, + EditorSelectFormFieldDefinition +} from "../../framework/forms"; +import {EditorUiContext} from "../../framework/core"; +import {setEditorContentFromHtml} from "../../../actions"; + +export const cellProperties: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + setEditorContentFromHtml(context.editor, formData.get('source')?.toString() || ''); + return true; + }, + fields: [ + { + build() { + const generalFields: EditorFormFieldDefinition[] = [ + { + label: 'Width', + name: 'width', + type: 'text', + }, + { + label: 'Height', + name: 'height', + type: 'text', + }, + { + label: 'Cell type', + name: 'type', + type: 'select', + valuesByLabel: { + 'Cell': 'cell', + 'Header cell': 'header', + } + } as EditorSelectFormFieldDefinition, + { + label: 'Horizontal align', + name: 'h_align', + type: 'select', + valuesByLabel: { + 'None': '', + 'Left': 'left', + 'Center': 'center', + 'Right': 'right', + } + } as EditorSelectFormFieldDefinition, + { + label: 'Vertical align', + name: 'v_align', + type: 'select', + valuesByLabel: { + 'None': '', + 'Top': 'top', + 'Middle': 'middle', + 'Bottom': 'bottom', + } + } as EditorSelectFormFieldDefinition, + ]; + + const advancedFields: EditorFormFieldDefinition[] = [ + { + label: 'Border width', + name: 'border_width', + type: 'text', + }, + { + label: 'Border style', + name: 'border_style', + type: 'select', + valuesByLabel: { + 'Select...': '', + "Solid": 'solid', + "Dotted": 'dotted', + "Dashed": 'dashed', + "Double": 'double', + "Groove": 'groove', + "Ridge": 'ridge', + "Inset": 'inset', + "Outset": 'outset', + "None": 'none', + "Hidden": 'hidden', + } + } as EditorSelectFormFieldDefinition, + { + label: 'Border color', + name: 'border_color', + type: 'text', + }, + { + label: 'Background color', + name: 'background_color', + type: 'text', + }, + ]; + + return new EditorFormTabs([ + { + label: 'General', + contents: generalFields, + }, + { + label: 'Advanced', + contents: advancedFields, + } + ]) + } + }, + ], +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts new file mode 100644 index 000000000..30351602c --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -0,0 +1,27 @@ +import {EditorFormModalDefinition} from "../framework/modals"; +import {image, link, media} from "./forms/objects"; +import {source} from "./forms/controls"; +import {cellProperties} from "./forms/tables"; + +export const modals: Record = { + link: { + title: 'Insert/Edit link', + form: link, + }, + image: { + title: 'Insert/Edit Image', + form: image, + }, + media: { + title: 'Insert/Edit Media', + form: media, + }, + source: { + title: 'Source code', + form: source, + }, + cell_properties: { + title: 'Cell Properties', + form: cellProperties, + }, +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts new file mode 100644 index 000000000..0557b37e5 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts @@ -0,0 +1,80 @@ +import {$getNodeByKey, LexicalEditor} from "lexical"; +import {NodeKey} from "lexical/LexicalNode"; +import { + $isTableNode, + applyTableHandlers, + HTMLTableElementWithWithTableSelectionState, + TableNode, + TableObserver +} from "@lexical/table"; +import {$isCustomTableNode, CustomTableNode} from "../../../nodes/custom-table"; + +// File adapted from logic in: +// https://github.com/facebook/lexical/blob/f373759a7849f473d34960a6bf4e34b2a011e762/packages/lexical-react/src/LexicalTablePlugin.ts#L49 +// Copyright (c) Meta Platforms, Inc. and affiliates. +// License: MIT + +class TableSelectionHandler { + + protected editor: LexicalEditor + protected tableSelections = new Map(); + protected unregisterMutationListener = () => {}; + + constructor(editor: LexicalEditor) { + this.editor = editor; + this.init(); + } + + protected init() { + this.unregisterMutationListener = this.editor.registerMutationListener(CustomTableNode, (mutations) => { + for (const [nodeKey, mutation] of mutations) { + if (mutation === 'created') { + this.editor.getEditorState().read(() => { + const tableNode = $getNodeByKey(nodeKey); + if ($isCustomTableNode(tableNode)) { + this.initializeTableNode(tableNode); + } + }); + } else if (mutation === 'destroyed') { + const tableSelection = this.tableSelections.get(nodeKey); + + if (tableSelection !== undefined) { + tableSelection.removeListeners(); + this.tableSelections.delete(nodeKey); + } + } + } + }); + } + + protected initializeTableNode(tableNode: TableNode) { + const nodeKey = tableNode.getKey(); + const tableElement = this.editor.getElementByKey( + nodeKey, + ) as HTMLTableElementWithWithTableSelectionState; + if (tableElement && !this.tableSelections.has(nodeKey)) { + const tableSelection = applyTableHandlers( + tableNode, + tableElement, + this.editor, + false, + ); + this.tableSelections.set(nodeKey, tableSelection); + } + }; + + teardown() { + this.unregisterMutationListener(); + for (const [, tableSelection] of this.tableSelections) { + tableSelection.removeListeners(); + } + } +} + +export function registerTableSelectionHandler(editor: LexicalEditor): (() => void) { + const resizer = new TableSelectionHandler(editor); + + return () => { + resizer.teardown(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index a3f150e52..5fbaec91b 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -6,11 +6,11 @@ import { getMainEditorFullToolbar, getTableToolbarContent } from "./toolbars"; import {EditorUIManager} from "./framework/manager"; -import {image as imageFormDefinition, link as linkFormDefinition, media as mediaFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions"; import {ImageDecorator} from "./decorators/image"; import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; import {DiagramDecorator} from "./decorators/diagram"; +import {modals} from "./defaults/modals"; export function buildEditorUI(container: HTMLElement, element: HTMLElement, scrollContainer: HTMLElement, editor: LexicalEditor, options: Record): EditorUiContext { const manager = new EditorUIManager(); @@ -30,22 +30,9 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro manager.setToolbar(getMainEditorFullToolbar()); // Register modals - manager.registerModal('link', { - title: 'Insert/Edit link', - form: linkFormDefinition, - }); - manager.registerModal('image', { - title: 'Insert/Edit Image', - form: imageFormDefinition - }); - manager.registerModal('media', { - title: 'Insert/Edit Media', - form: mediaFormDefinition, - }); - manager.registerModal('source', { - title: 'Source code', - form: sourceFormDefinition, - }); + for (const key of Object.keys(modals)) { + manager.registerModal(key, modals[key]); + } // Register context toolbars manager.registerContextToolbar('image', { diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index d2b179eb6..43f00c001 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -9,12 +9,13 @@ import {EditorTableCreator} from "./framework/blocks/table-creator"; import {EditorColorButton} from "./framework/blocks/color-button"; import {EditorOverflowContainer} from "./framework/blocks/overflow-container"; import { + cellProperties, deleteColumn, deleteRow, deleteTable, deleteTableMenuAction, insertColumnAfter, insertColumnBefore, insertRowAbove, - insertRowBelow, + insertRowBelow, mergeCells, splitCell, table } from "./defaults/buttons/tables"; import {fullscreen, redo, source, undo} from "./defaults/buttons/controls"; @@ -118,6 +119,11 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorDropdownButton({button: {...table, format: 'long'}, showOnHover: true}, [ new EditorTableCreator(), ]), + new EditorDropdownButton({button: {label: 'Cell'}}, [ + new EditorButton(cellProperties), + new EditorButton(mergeCells), + new EditorButton(splitCell), + ]), new EditorButton(deleteTableMenuAction), ]),