Lexical: Started on form UI

This commit is contained in:
Dan Brown 2024-05-30 16:50:55 +01:00
parent 57259aee00
commit ae98745439
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
8 changed files with 223 additions and 31 deletions

View File

@ -3,7 +3,6 @@ import {
$createParagraphNode, $createParagraphNode,
$isParagraphNode, $isParagraphNode,
BaseSelection, FORMAT_TEXT_COMMAND, BaseSelection, FORMAT_TEXT_COMMAND,
LexicalEditor,
LexicalNode, LexicalNode,
REDO_COMMAND, TextFormatType, REDO_COMMAND, TextFormatType,
UNDO_COMMAND UNDO_COMMAND
@ -19,11 +18,12 @@ import {
HeadingTagType HeadingTagType
} from "@lexical/rich-text"; } from "@lexical/rich-text";
import {$isLinkNode, $toggleLink} from "@lexical/link"; import {$isLinkNode, $toggleLink} from "@lexical/link";
import {EditorUiContext} from "../framework/core";
export const undo: EditorButtonDefinition = { export const undo: EditorButtonDefinition = {
label: 'Undo', label: 'Undo',
action(editor: LexicalEditor) { action(context: EditorUiContext) {
editor.dispatchCommand(UNDO_COMMAND, undefined); context.editor.dispatchCommand(UNDO_COMMAND, undefined);
}, },
isActive(selection: BaseSelection|null): boolean { isActive(selection: BaseSelection|null): boolean {
return false; return false;
@ -32,8 +32,8 @@ export const undo: EditorButtonDefinition = {
export const redo: EditorButtonDefinition = { export const redo: EditorButtonDefinition = {
label: 'Redo', label: 'Redo',
action(editor: LexicalEditor) { action(context: EditorUiContext) {
editor.dispatchCommand(REDO_COMMAND, undefined); context.editor.dispatchCommand(REDO_COMMAND, undefined);
}, },
isActive(selection: BaseSelection|null): boolean { isActive(selection: BaseSelection|null): boolean {
return false; return false;
@ -43,9 +43,9 @@ export const redo: EditorButtonDefinition = {
function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition {
return { return {
label: `${name} Callout`, label: `${name} Callout`,
action(editor: LexicalEditor) { action(context: EditorUiContext) {
toggleSelectionBlockNodeType( toggleSelectionBlockNodeType(
editor, context.editor,
(node) => $isCalloutNodeOfCategory(node, category), (node) => $isCalloutNodeOfCategory(node, category),
() => $createCalloutNode(category), () => $createCalloutNode(category),
) )
@ -68,9 +68,9 @@ const isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTag
function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefinition { function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefinition {
return { return {
label: name, label: name,
action(editor: LexicalEditor) { action(context: EditorUiContext) {
toggleSelectionBlockNodeType( toggleSelectionBlockNodeType(
editor, context.editor,
(node) => isHeaderNodeOfTag(node, tag), (node) => isHeaderNodeOfTag(node, tag),
() => $createHeadingNode(tag), () => $createHeadingNode(tag),
) )
@ -88,8 +88,8 @@ export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header')
export const blockquote: EditorButtonDefinition = { export const blockquote: EditorButtonDefinition = {
label: 'Blockquote', label: 'Blockquote',
action(editor: LexicalEditor) { action(context: EditorUiContext) {
toggleSelectionBlockNodeType(editor, $isQuoteNode, $createQuoteNode); toggleSelectionBlockNodeType(context.editor, $isQuoteNode, $createQuoteNode);
}, },
isActive(selection: BaseSelection|null): boolean { isActive(selection: BaseSelection|null): boolean {
return selectionContainsNodeType(selection, $isQuoteNode); return selectionContainsNodeType(selection, $isQuoteNode);
@ -98,8 +98,8 @@ export const blockquote: EditorButtonDefinition = {
export const paragraph: EditorButtonDefinition = { export const paragraph: EditorButtonDefinition = {
label: 'Paragraph', label: 'Paragraph',
action(editor: LexicalEditor) { action(context: EditorUiContext) {
toggleSelectionBlockNodeType(editor, $isParagraphNode, $createParagraphNode); toggleSelectionBlockNodeType(context.editor, $isParagraphNode, $createParagraphNode);
}, },
isActive(selection: BaseSelection|null): boolean { isActive(selection: BaseSelection|null): boolean {
return selectionContainsNodeType(selection, $isParagraphNode); return selectionContainsNodeType(selection, $isParagraphNode);
@ -109,8 +109,8 @@ export const paragraph: EditorButtonDefinition = {
function buildFormatButton(label: string, format: TextFormatType): EditorButtonDefinition { function buildFormatButton(label: string, format: TextFormatType): EditorButtonDefinition {
return { return {
label: label, label: label,
action(editor: LexicalEditor) { action(context: EditorUiContext) {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); context.editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
}, },
isActive(selection: BaseSelection|null): boolean { isActive(selection: BaseSelection|null): boolean {
return selectionContainsTextFormat(selection, format); return selectionContainsTextFormat(selection, format);
@ -132,8 +132,8 @@ export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'co
export const link: EditorButtonDefinition = { export const link: EditorButtonDefinition = {
label: 'Insert/edit link', label: 'Insert/edit link',
action(editor: LexicalEditor) { action(context: EditorUiContext) {
editor.update(() => { context.editor.update(() => {
$toggleLink('http://example.com'); $toggleLink('http://example.com');
}) })
}, },

View File

@ -0,0 +1,43 @@
import {EditorFormDefinition, EditorFormFieldDefinition, EditorSelectFormFieldDefinition} from "../framework/forms";
import {EditorUiContext} from "../framework/core";
export const link: EditorFormDefinition = {
submitText: 'Apply',
cancelText: 'Cancel',
action(formData, context: EditorUiContext) {
// Todo
console.log('link-form-action', formData);
return true;
},
cancel() {
// Todo
console.log('link-form-cancel');
},
fields: [
{
label: 'URL',
name: 'url',
type: 'text',
},
{
label: 'Text to display',
name: 'text',
type: 'text',
},
{
label: 'Title',
name: 'title',
type: 'text',
},
{
label: 'Open link in...',
name: 'target',
type: 'select',
valuesByLabel: {
'Current window': '',
'New window': '_blank',
}
} as EditorSelectFormFieldDefinition,
],
};

View File

@ -1,15 +1,16 @@
import {BaseSelection, LexicalEditor} from "lexical"; import {BaseSelection} from "lexical";
import {EditorUiElement, EditorUiStateUpdate} from "./base-elements"; import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
import {el} from "../../helpers"; import {el} from "../../helpers";
export interface EditorButtonDefinition { export interface EditorButtonDefinition {
label: string; label: string;
action: (editor: LexicalEditor) => void; action: (context: EditorUiContext) => void;
isActive: (selection: BaseSelection|null) => boolean; isActive: (selection: BaseSelection|null) => boolean;
} }
export class EditorButton extends EditorUiElement { export class EditorButton extends EditorUiElement {
protected definition: EditorButtonDefinition; protected definition: EditorButtonDefinition;
protected active: boolean = false;
constructor(definition: EditorButtonDefinition) { constructor(definition: EditorButtonDefinition) {
super(); super();
@ -20,7 +21,7 @@ export class EditorButton extends EditorUiElement {
const button = el('button', { const button = el('button', {
type: 'button', type: 'button',
class: 'editor-button', class: 'editor-button',
}, [this.definition.label]) as HTMLButtonElement; }, [this.getLabel()]) as HTMLButtonElement;
button.addEventListener('click', this.onClick.bind(this)); button.addEventListener('click', this.onClick.bind(this));
@ -28,17 +29,25 @@ export class EditorButton extends EditorUiElement {
} }
protected onClick() { protected onClick() {
this.definition.action(this.getContext().editor); this.definition.action(this.getContext());
} }
updateActiveState(selection: BaseSelection|null) { updateActiveState(selection: BaseSelection|null) {
const isActive = this.definition.isActive(selection); this.active = this.definition.isActive(selection);
this.dom?.classList.toggle('editor-button-active', isActive); this.dom?.classList.toggle('editor-button-active', this.active);
} }
updateState(state: EditorUiStateUpdate): void { updateState(state: EditorUiStateUpdate): void {
this.updateActiveState(state.selection); this.updateActiveState(state.selection);
} }
isActive(): boolean {
return this.active;
}
getLabel(): string {
return this.trans(this.definition.label);
}
} }
export class FormatPreviewButton extends EditorButton { export class FormatPreviewButton extends EditorButton {
@ -55,7 +64,7 @@ export class FormatPreviewButton extends EditorButton {
const preview = el('span', { const preview = el('span', {
class: 'editor-button-format-preview' class: 'editor-button-format-preview'
}, [this.definition.label]); }, [this.getLabel()]);
const stylesToApply = this.getStylesFromPreview(); const stylesToApply = this.getStylesFromPreview();
console.log(stylesToApply); console.log(stylesToApply);
@ -70,7 +79,7 @@ export class FormatPreviewButton extends EditorButton {
protected getStylesFromPreview(): Record<string, string> { protected getStylesFromPreview(): Record<string, string> {
const wrap = el('div', {style: 'display: none', hidden: 'true', class: 'page-content'}); const wrap = el('div', {style: 'display: none', hidden: 'true', class: 'page-content'});
const sampleClone = this.previewSampleElement.cloneNode() as HTMLElement; const sampleClone = this.previewSampleElement.cloneNode() as HTMLElement;
sampleClone.textContent = this.definition.label; sampleClone.textContent = this.getLabel();
wrap.append(sampleClone); wrap.append(sampleClone);
document.body.append(wrap); document.body.append(wrap);

View File

@ -1,5 +1,6 @@
import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./base-elements"; import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core";
import {el} from "../../helpers"; import {el} from "../../helpers";
import {EditorButton} from "./buttons";
export class EditorContainerUiElement extends EditorUiElement { export class EditorContainerUiElement extends EditorUiElement {
protected children : EditorUiElement[]; protected children : EditorUiElement[];
@ -24,6 +25,7 @@ export class EditorContainerUiElement extends EditorUiElement {
} }
setContext(context: EditorUiContext) { setContext(context: EditorUiContext) {
super.setContext(context);
for (const child of this.getChildren()) { for (const child of this.getChildren()) {
child.setContext(context); child.setContext(context);
} }
@ -54,9 +56,9 @@ export class EditorFormatMenu extends EditorContainerUiElement {
}, childElements); }, childElements);
const toggle = el('button', { const toggle = el('button', {
class: 'editor-format-menu-toggle', class: 'editor-format-menu-toggle editor-button',
type: 'button', type: 'button',
}, ['Formats']); }, [this.trans('Formats')]);
const wrapper = el('div', { const wrapper = el('div', {
class: 'editor-format-menu editor-dropdown-menu-container', class: 'editor-format-menu editor-dropdown-menu-container',
@ -88,4 +90,24 @@ export class EditorFormatMenu extends EditorContainerUiElement {
return wrapper; return wrapper;
} }
updateState(state: EditorUiStateUpdate) {
super.updateState(state);
for (const child of this.children) {
if (child instanceof EditorButton && child.isActive()) {
this.updateToggleLabel(child.getLabel());
return;
}
}
this.updateToggleLabel(this.trans('Formats'));
}
protected updateToggleLabel(text: string): void {
const button = this.getDOMElement().querySelector('button');
if (button) {
button.innerText = text;
}
}
} }

View File

@ -1,4 +1,5 @@
import {BaseSelection, LexicalEditor} from "lexical"; import {BaseSelection, LexicalEditor} from "lexical";
import {EditorUIManager} from "./manager";
export type EditorUiStateUpdate = { export type EditorUiStateUpdate = {
editor: LexicalEditor, editor: LexicalEditor,
@ -7,6 +8,8 @@ export type EditorUiStateUpdate = {
export type EditorUiContext = { export type EditorUiContext = {
editor: LexicalEditor, editor: LexicalEditor,
translate: (text: string) => string,
manager: EditorUIManager,
}; };
export abstract class EditorUiElement { export abstract class EditorUiElement {
@ -35,5 +38,11 @@ export abstract class EditorUiElement {
return this.dom; return this.dom;
} }
abstract updateState(state: EditorUiStateUpdate): void; trans(text: string) {
return this.getContext().translate(text);
}
updateState(state: EditorUiStateUpdate): void {
return;
}
} }

View File

@ -0,0 +1,82 @@
import {EditorUiContext, EditorUiElement} from "./core";
import {EditorContainerUiElement} from "./containers";
import {el} from "../../helpers";
export interface EditorFormFieldDefinition {
label: string;
name: string;
type: 'text' | 'select';
}
export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition {
type: 'select',
valuesByLabel: Record<string, string>
}
export interface EditorFormDefinition {
submitText: string;
cancelText: string;
action: (formData: FormData, context: EditorUiContext) => boolean;
cancel: () => void;
fields: EditorFormFieldDefinition[];
}
export class EditorFormField extends EditorUiElement {
protected definition: EditorFormFieldDefinition;
constructor(definition: EditorFormFieldDefinition) {
super();
this.definition = definition;
}
protected buildDOM(): HTMLElement {
const id = `editor-form-field-${this.definition.name}-${Date.now()}`;
let input: HTMLElement;
if (this.definition.type === 'select') {
const options = (this.definition as EditorSelectFormFieldDefinition).valuesByLabel
const labels = Object.keys(options);
const optionElems = labels.map(label => el('option', {value: options[label]}, [label]));
input = el('select', {id, name: this.definition.name, class: 'editor-form-field-input'}, optionElems);
} else {
input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'});
}
return el('div', {class: 'editor-form-field-wrapper'}, [
el('label', {class: 'editor-form-field-label', for: id}, [this.trans(this.definition.label)]),
input,
]);
}
}
export class EditorForm extends EditorContainerUiElement {
protected definition: EditorFormDefinition;
constructor(definition: EditorFormDefinition) {
super(definition.fields.map(fieldDefinition => new EditorFormField(fieldDefinition)));
this.definition = definition;
}
protected buildDOM(): HTMLElement {
const cancelButton = el('button', {type: 'button', class: 'editor-form-action-secondary'}, [this.trans(this.definition.cancelText)]);
const form = el('form', {}, [
...this.children.map(child => child.getDOMElement()),
el('div', {class: 'editor-form-actions'}, [
cancelButton,
el('button', {type: 'submit', class: 'editor-form-action-primary'}, [this.trans(this.definition.submitText)]),
])
]);
form.addEventListener('submit', (event) => {
event.preventDefault();
const formData = new FormData(form as HTMLFormElement);
this.definition.action(formData, this.getContext());
});
cancelButton.addEventListener('click', (event) => {
this.definition.cancel();
});
return form;
}
}

View File

@ -0,0 +1,11 @@
export class EditorUIManager {
// Todo - Register and show modal via this
// (Part of UI context)
}

View File

@ -5,12 +5,28 @@ import {
SELECTION_CHANGE_COMMAND SELECTION_CHANGE_COMMAND
} from "lexical"; } from "lexical";
import {getMainEditorFullToolbar} from "./toolbars"; import {getMainEditorFullToolbar} from "./toolbars";
import {EditorUIManager} from "./framework/manager";
import {EditorForm} from "./framework/forms";
import {link} from "./defaults/form-definitions";
export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) {
const manager = new EditorUIManager();
const context = {
editor,
manager,
translate: (text: string): string => text,
};
// Create primary toolbar
const toolbar = getMainEditorFullToolbar(); const toolbar = getMainEditorFullToolbar();
toolbar.setContext({editor}); toolbar.setContext(context);
element.before(toolbar.getDOMElement()); element.before(toolbar.getDOMElement());
// Form test
const linkForm = new EditorForm(link);
linkForm.setContext(context);
element.before(linkForm.getDOMElement());
// Update button states on editor selection change // Update button states on editor selection change
editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
const selection = $getSelection(); const selection = $getSelection();