Lexical: Started on table actions

Started building table cell form/actions
This commit is contained in:
Dan Brown 2024-08-02 15:28:54 +01:00
parent 6b06d490c5
commit a27a325af7
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
11 changed files with 361 additions and 82 deletions

View File

@ -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<string, any> = {}): 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),
);

View File

@ -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;
}

View File

@ -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.

View File

@ -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;
}
};

View File

@ -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',
},
],
};

View File

@ -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',
},
],
};

View File

@ -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,
}
])
}
},
],
};

View File

@ -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<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,
},
};

View File

@ -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<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();
};
}

View File

@ -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<string, any>): 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', {

View File

@ -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),
]),