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<string, any> = {}): SimpleWysiwygEditorInterface {
const config: CreateEditorArgs = {
registerRichText(editor),
registerHistory(editor, createEmptyHistoryState(), 300),
registerTableResizer(editor, editWrap),
+ registerTableSelectionHandler(editor),
registerTaskListHandler(editor, editArea),
);
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;
}
## In progress
- Table features
- - Continued table dropdown menu
+ - Continued table dropdown menu
+ - Connect up cell properties form
+ - Merge cell action
## Main Todo
- 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.
$deleteTableColumn__EXPERIMENTAL,
$deleteTableRow__EXPERIMENTAL,
$insertTableColumn__EXPERIMENTAL,
- $insertTableRow__EXPERIMENTAL,
- $isTableNode,
+ $insertTableRow__EXPERIMENTAL, $isTableCellNode,
+ $isTableNode, $isTableSelection, $unmergeCell, TableCellNode,
} from "@lexical/table";
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
--- /dev/null
+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
-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',
],
};
-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) {
}
},
],
-};
-
-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
--- /dev/null
+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
--- /dev/null
+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<string, EditorFormModalDefinition> = {
+ 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
--- /dev/null
+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<NodeKey, TableObserver>();
+ 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<CustomTableNode>(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
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<string, any>): EditorUiContext {
const manager = new EditorUIManager();
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', {
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";
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),
]),