From ae987454392a994b350841cbc35f61e0903eebaa Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 30 May 2024 16:50:55 +0100 Subject: [PATCH] Lexical: Started on form UI --- .../wysiwyg/ui/defaults/button-definitions.ts | 34 ++++---- .../wysiwyg/ui/defaults/form-definitions.ts | 43 ++++++++++ resources/js/wysiwyg/ui/framework/buttons.ts | 27 ++++-- .../js/wysiwyg/ui/framework/containers.ts | 28 ++++++- .../framework/{base-elements.ts => core.ts} | 11 ++- resources/js/wysiwyg/ui/framework/forms.ts | 82 +++++++++++++++++++ resources/js/wysiwyg/ui/framework/manager.ts | 11 +++ resources/js/wysiwyg/ui/index.ts | 18 +++- 8 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 resources/js/wysiwyg/ui/defaults/form-definitions.ts rename resources/js/wysiwyg/ui/framework/{base-elements.ts => core.ts} (75%) create mode 100644 resources/js/wysiwyg/ui/framework/forms.ts create mode 100644 resources/js/wysiwyg/ui/framework/manager.ts diff --git a/resources/js/wysiwyg/ui/defaults/button-definitions.ts b/resources/js/wysiwyg/ui/defaults/button-definitions.ts index 874f632fe..da0a1e2c5 100644 --- a/resources/js/wysiwyg/ui/defaults/button-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/button-definitions.ts @@ -3,7 +3,6 @@ import { $createParagraphNode, $isParagraphNode, BaseSelection, FORMAT_TEXT_COMMAND, - LexicalEditor, LexicalNode, REDO_COMMAND, TextFormatType, UNDO_COMMAND @@ -19,11 +18,12 @@ import { HeadingTagType } from "@lexical/rich-text"; import {$isLinkNode, $toggleLink} from "@lexical/link"; +import {EditorUiContext} from "../framework/core"; export const undo: EditorButtonDefinition = { label: 'Undo', - action(editor: LexicalEditor) { - editor.dispatchCommand(UNDO_COMMAND, undefined); + action(context: EditorUiContext) { + context.editor.dispatchCommand(UNDO_COMMAND, undefined); }, isActive(selection: BaseSelection|null): boolean { return false; @@ -32,8 +32,8 @@ export const undo: EditorButtonDefinition = { export const redo: EditorButtonDefinition = { label: 'Redo', - action(editor: LexicalEditor) { - editor.dispatchCommand(REDO_COMMAND, undefined); + action(context: EditorUiContext) { + context.editor.dispatchCommand(REDO_COMMAND, undefined); }, isActive(selection: BaseSelection|null): boolean { return false; @@ -43,9 +43,9 @@ export const redo: EditorButtonDefinition = { function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { return { label: `${name} Callout`, - action(editor: LexicalEditor) { + action(context: EditorUiContext) { toggleSelectionBlockNodeType( - editor, + context.editor, (node) => $isCalloutNodeOfCategory(node, category), () => $createCalloutNode(category), ) @@ -68,9 +68,9 @@ const isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTag function buildHeaderButton(tag: HeadingTagType, name: string): EditorButtonDefinition { return { label: name, - action(editor: LexicalEditor) { + action(context: EditorUiContext) { toggleSelectionBlockNodeType( - editor, + context.editor, (node) => isHeaderNodeOfTag(node, tag), () => $createHeadingNode(tag), ) @@ -88,8 +88,8 @@ export const h5: EditorButtonDefinition = buildHeaderButton('h5', 'Tiny Header') export const blockquote: EditorButtonDefinition = { label: 'Blockquote', - action(editor: LexicalEditor) { - toggleSelectionBlockNodeType(editor, $isQuoteNode, $createQuoteNode); + action(context: EditorUiContext) { + toggleSelectionBlockNodeType(context.editor, $isQuoteNode, $createQuoteNode); }, isActive(selection: BaseSelection|null): boolean { return selectionContainsNodeType(selection, $isQuoteNode); @@ -98,8 +98,8 @@ export const blockquote: EditorButtonDefinition = { export const paragraph: EditorButtonDefinition = { label: 'Paragraph', - action(editor: LexicalEditor) { - toggleSelectionBlockNodeType(editor, $isParagraphNode, $createParagraphNode); + action(context: EditorUiContext) { + toggleSelectionBlockNodeType(context.editor, $isParagraphNode, $createParagraphNode); }, isActive(selection: BaseSelection|null): boolean { return selectionContainsNodeType(selection, $isParagraphNode); @@ -109,8 +109,8 @@ export const paragraph: EditorButtonDefinition = { function buildFormatButton(label: string, format: TextFormatType): EditorButtonDefinition { return { label: label, - action(editor: LexicalEditor) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); + action(context: EditorUiContext) { + context.editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); }, isActive(selection: BaseSelection|null): boolean { return selectionContainsTextFormat(selection, format); @@ -132,8 +132,8 @@ export const code: EditorButtonDefinition = buildFormatButton('Inline Code', 'co export const link: EditorButtonDefinition = { label: 'Insert/edit link', - action(editor: LexicalEditor) { - editor.update(() => { + action(context: EditorUiContext) { + context.editor.update(() => { $toggleLink('http://example.com'); }) }, diff --git a/resources/js/wysiwyg/ui/defaults/form-definitions.ts b/resources/js/wysiwyg/ui/defaults/form-definitions.ts new file mode 100644 index 000000000..c8477d9f2 --- /dev/null +++ b/resources/js/wysiwyg/ui/defaults/form-definitions.ts @@ -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, + ], +}; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/buttons.ts b/resources/js/wysiwyg/ui/framework/buttons.ts index 2a6f5a976..48046e9de 100644 --- a/resources/js/wysiwyg/ui/framework/buttons.ts +++ b/resources/js/wysiwyg/ui/framework/buttons.ts @@ -1,15 +1,16 @@ -import {BaseSelection, LexicalEditor} from "lexical"; -import {EditorUiElement, EditorUiStateUpdate} from "./base-elements"; +import {BaseSelection} from "lexical"; +import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {el} from "../../helpers"; export interface EditorButtonDefinition { label: string; - action: (editor: LexicalEditor) => void; + action: (context: EditorUiContext) => void; isActive: (selection: BaseSelection|null) => boolean; } export class EditorButton extends EditorUiElement { protected definition: EditorButtonDefinition; + protected active: boolean = false; constructor(definition: EditorButtonDefinition) { super(); @@ -20,7 +21,7 @@ export class EditorButton extends EditorUiElement { const button = el('button', { type: 'button', class: 'editor-button', - }, [this.definition.label]) as HTMLButtonElement; + }, [this.getLabel()]) as HTMLButtonElement; button.addEventListener('click', this.onClick.bind(this)); @@ -28,17 +29,25 @@ export class EditorButton extends EditorUiElement { } protected onClick() { - this.definition.action(this.getContext().editor); + this.definition.action(this.getContext()); } updateActiveState(selection: BaseSelection|null) { - const isActive = this.definition.isActive(selection); - this.dom?.classList.toggle('editor-button-active', isActive); + this.active = this.definition.isActive(selection); + this.dom?.classList.toggle('editor-button-active', this.active); } updateState(state: EditorUiStateUpdate): void { this.updateActiveState(state.selection); } + + isActive(): boolean { + return this.active; + } + + getLabel(): string { + return this.trans(this.definition.label); + } } export class FormatPreviewButton extends EditorButton { @@ -55,7 +64,7 @@ export class FormatPreviewButton extends EditorButton { const preview = el('span', { class: 'editor-button-format-preview' - }, [this.definition.label]); + }, [this.getLabel()]); const stylesToApply = this.getStylesFromPreview(); console.log(stylesToApply); @@ -70,7 +79,7 @@ export class FormatPreviewButton extends EditorButton { protected getStylesFromPreview(): Record { const wrap = el('div', {style: 'display: none', hidden: 'true', class: 'page-content'}); const sampleClone = this.previewSampleElement.cloneNode() as HTMLElement; - sampleClone.textContent = this.definition.label; + sampleClone.textContent = this.getLabel(); wrap.append(sampleClone); document.body.append(wrap); diff --git a/resources/js/wysiwyg/ui/framework/containers.ts b/resources/js/wysiwyg/ui/framework/containers.ts index e58988e7b..ed191a882 100644 --- a/resources/js/wysiwyg/ui/framework/containers.ts +++ b/resources/js/wysiwyg/ui/framework/containers.ts @@ -1,5 +1,6 @@ -import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./base-elements"; +import {EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {el} from "../../helpers"; +import {EditorButton} from "./buttons"; export class EditorContainerUiElement extends EditorUiElement { protected children : EditorUiElement[]; @@ -24,6 +25,7 @@ export class EditorContainerUiElement extends EditorUiElement { } setContext(context: EditorUiContext) { + super.setContext(context); for (const child of this.getChildren()) { child.setContext(context); } @@ -54,9 +56,9 @@ export class EditorFormatMenu extends EditorContainerUiElement { }, childElements); const toggle = el('button', { - class: 'editor-format-menu-toggle', + class: 'editor-format-menu-toggle editor-button', type: 'button', - }, ['Formats']); + }, [this.trans('Formats')]); const wrapper = el('div', { class: 'editor-format-menu editor-dropdown-menu-container', @@ -88,4 +90,24 @@ export class EditorFormatMenu extends EditorContainerUiElement { 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; + } + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/base-elements.ts b/resources/js/wysiwyg/ui/framework/core.ts similarity index 75% rename from resources/js/wysiwyg/ui/framework/base-elements.ts rename to resources/js/wysiwyg/ui/framework/core.ts index 665011782..68d845b42 100644 --- a/resources/js/wysiwyg/ui/framework/base-elements.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -1,4 +1,5 @@ import {BaseSelection, LexicalEditor} from "lexical"; +import {EditorUIManager} from "./manager"; export type EditorUiStateUpdate = { editor: LexicalEditor, @@ -7,6 +8,8 @@ export type EditorUiStateUpdate = { export type EditorUiContext = { editor: LexicalEditor, + translate: (text: string) => string, + manager: EditorUIManager, }; export abstract class EditorUiElement { @@ -35,5 +38,11 @@ export abstract class EditorUiElement { return this.dom; } - abstract updateState(state: EditorUiStateUpdate): void; + trans(text: string) { + return this.getContext().translate(text); + } + + updateState(state: EditorUiStateUpdate): void { + return; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts new file mode 100644 index 000000000..0fce73c12 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -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 +} + +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; + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts new file mode 100644 index 000000000..f1a34c92a --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -0,0 +1,11 @@ + + + + + +export class EditorUIManager { + + // Todo - Register and show modal via this + // (Part of UI context) + +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 56ae9354a..5a0d7fd2d 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -5,12 +5,28 @@ import { SELECTION_CHANGE_COMMAND } from "lexical"; 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) { + const manager = new EditorUIManager(); + const context = { + editor, + manager, + translate: (text: string): string => text, + }; + + // Create primary toolbar const toolbar = getMainEditorFullToolbar(); - toolbar.setContext({editor}); + toolbar.setContext(context); 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 editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { const selection = $getSelection();