]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Added form complex/tab ui support
authorDan Brown <redacted>
Sun, 28 Jul 2024 11:48:58 +0000 (12:48 +0100)
committerDan Brown <redacted>
Sun, 28 Jul 2024 11:48:58 +0000 (12:48 +0100)
resources/js/wysiwyg/todo.md
resources/js/wysiwyg/ui/defaults/form-definitions.ts
resources/js/wysiwyg/ui/framework/core.ts
resources/js/wysiwyg/ui/framework/forms.ts
resources/sass/_editor.scss

index 2125aa25878221309addc7cb69afb5f03294f9c4..49f685beabfc7db60b85e98f7052dfa7e95100ba 100644 (file)
@@ -2,8 +2,7 @@
 
 ## In progress
 
-- Update forms to allow panels (Media)
-  - Will be used for table forms also. 
+//
 
 ## Main Todo
 
@@ -23,6 +22,7 @@
 - Image gallery integration for form
 - Drawing gallery integration
 - 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)
 
 ## Bugs
 
index a2242c338739f8993939a755eb3964e13c399b1b..6c0a54f2333024acff0aede76846f85d4e99d762 100644 (file)
@@ -1,4 +1,4 @@
-import {EditorFormDefinition, EditorSelectFormFieldDefinition} from "../framework/forms";
+import {EditorFormDefinition, EditorFormTabs, EditorSelectFormFieldDefinition} from "../framework/forms";
 import {EditorUiContext} from "../framework/core";
 import {$createLinkNode} from "@lexical/link";
 import {$createTextNode, $getSelection, LexicalNode} from "lexical";
@@ -133,25 +133,40 @@ export const media: EditorFormDefinition = {
     },
     fields: [
         {
-            label: 'Source',
-            name: 'src',
-            type: 'text',
-        },
-        {
-            label: 'Width',
-            name: 'width',
-            type: 'text',
-        },
-        {
-            label: 'Height',
-            name: 'height',
-            type: 'text',
-        },
-        // TODO - Tabbed interface to separate this option
-        {
-            label: 'Paste your embed code below:',
-            name: 'embed',
-            type: 'textarea',
+            build() {
+                return new EditorFormTabs([
+                    {
+                        label: 'General',
+                        contents: [
+                            {
+                                label: 'Source',
+                                name: 'src',
+                                type: 'text',
+                            },
+                            {
+                                label: 'Width',
+                                name: 'width',
+                                type: 'text',
+                            },
+                            {
+                                label: 'Height',
+                                name: 'height',
+                                type: 'text',
+                            },
+                        ],
+                    },
+                    {
+                        label: 'Embed',
+                        contents: [
+                            {
+                                label: 'Paste your embed code below:',
+                                name: 'embed',
+                                type: 'textarea',
+                            },
+                        ],
+                    }
+                ])
+            }
         },
     ],
 };
index c8f390c4803c8fdcd01e67542af7e88dab127e33..f644bc37a700f58280c5ef88ecdfcd53a035fcc7 100644 (file)
@@ -18,6 +18,14 @@ export type EditorUiContext = {
     options: Record<string, any>; // General user options which may be used by sub elements
 };
 
+export interface EditorUiBuilderDefinition {
+    build: () => EditorUiElement;
+}
+
+export function isUiBuilderDefinition(object: any): object is EditorUiBuilderDefinition {
+    return 'build' in object;
+}
+
 export abstract class EditorUiElement {
     protected dom: HTMLElement|null = null;
     private context: EditorUiContext|null = null;
index b641f993bd1a4bca881884e2aa48cf892e6bb68f..b225a3de2c2a190145995c152b95b67acf004dc5 100644 (file)
@@ -1,5 +1,12 @@
-import {EditorUiContext, EditorUiElement, EditorContainerUiElement} from "./core";
+import {
+    EditorUiContext,
+    EditorUiElement,
+    EditorContainerUiElement,
+    EditorUiBuilderDefinition,
+    isUiBuilderDefinition
+} from "./core";
 import {el} from "../../helpers";
+import {uniqueId} from "../../../services/util";
 
 export interface EditorFormFieldDefinition {
     label: string;
@@ -12,10 +19,15 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti
     valuesByLabel: Record<string, string>
 }
 
+interface EditorFormTabDefinition {
+    label: string;
+    contents: EditorFormFieldDefinition[];
+}
+
 export interface EditorFormDefinition {
     submitText: string;
     action: (formData: FormData, context: EditorUiContext) => Promise<boolean>;
-    fields: EditorFormFieldDefinition[];
+    fields: (EditorFormFieldDefinition|EditorUiBuilderDefinition)[];
 }
 
 export class EditorFormField extends EditorUiElement {
@@ -62,7 +74,14 @@ export class EditorForm extends EditorContainerUiElement {
     protected onCancel: null|(() => void) = null;
 
     constructor(definition: EditorFormDefinition) {
-        super(definition.fields.map(fieldDefinition => new EditorFormField(fieldDefinition)));
+        let children: (EditorFormField|EditorUiElement)[] = definition.fields.map(fieldDefinition => {
+            if (isUiBuilderDefinition(fieldDefinition)) {
+                return fieldDefinition.build();
+            }
+            return new EditorFormField(fieldDefinition)
+        });
+
+        super(children);
         this.definition = definition;
     }
 
@@ -80,13 +99,23 @@ export class EditorForm extends EditorContainerUiElement {
     }
 
     protected getFieldByName(name: string): EditorFormField|null {
-        for (const child of this.children as EditorFormField[]) {
-            if (child.getName() === name) {
-                return child;
+
+        const search = (children: EditorUiElement[]): EditorFormField|null => {
+            for (const child of children) {
+                if (child instanceof EditorFormField && child.getName() === name) {
+                    return child;
+                } else if (child instanceof EditorContainerUiElement) {
+                    const matchingChild = search(child.getChildren());
+                    if (matchingChild) {
+                        return matchingChild;
+                    }
+                }
             }
-        }
 
-        return null;
+            return null;
+        };
+
+        return search(this.getChildren());
     }
 
     protected buildDOM(): HTMLElement {
@@ -113,4 +142,91 @@ export class EditorForm extends EditorContainerUiElement {
 
         return form;
     }
+}
+
+export class EditorFormTab extends EditorContainerUiElement {
+
+    protected definition: EditorFormTabDefinition;
+    protected fields: EditorFormField[];
+    protected id: string;
+
+    constructor(definition: EditorFormTabDefinition) {
+        const fields = definition.contents.map(fieldDef => new EditorFormField(fieldDef));
+        super(fields);
+
+        this.definition = definition;
+        this.fields = fields;
+        this.id = uniqueId();
+    }
+
+    public getLabel(): string {
+        return this.getContext().translate(this.definition.label);
+    }
+
+    public getId(): string {
+        return this.id;
+    }
+
+    protected buildDOM(): HTMLElement {
+        return el(
+            'div',
+            {
+                class: 'editor-form-tab-content',
+                role: 'tabpanel',
+                id: `editor-tabpanel-${this.id}`,
+                'aria-labelledby': `editor-tab-${this.id}`,
+            },
+            this.fields.map(f => f.getDOMElement())
+        );
+    }
+}
+export class EditorFormTabs extends EditorContainerUiElement {
+
+    protected definitions: EditorFormTabDefinition[] = [];
+    protected tabs: EditorFormTab[] = [];
+
+    constructor(definitions: EditorFormTabDefinition[]) {
+        const tabs: EditorFormTab[] = definitions.map(d => new EditorFormTab(d));
+        super(tabs);
+
+        this.definitions = definitions;
+        this.tabs = tabs;
+    }
+
+    protected buildDOM(): HTMLElement {
+        const controls: HTMLElement[] = [];
+        const contents: HTMLElement[] = [];
+
+        const selectTab = (tabIndex: number) => {
+            for (let i = 0; i < controls.length; i++) {
+                controls[i].setAttribute('aria-selected', (i === tabIndex) ? 'true' : 'false');
+            }
+            for (let i = 0; i < contents.length; i++) {
+                contents[i].hidden = !(i === tabIndex);
+            }
+        };
+
+        for (const tab of this.tabs) {
+            const button = el('button', {
+                class: 'editor-form-tab-control',
+                type: 'button',
+                role: 'tab',
+                id: `editor-tab-${tab.getId()}`,
+                'aria-controls': `editor-tabpanel-${tab.getId()}`
+            }, [tab.getLabel()]);
+            contents.push(tab.getDOMElement());
+            controls.push(button);
+
+            button.addEventListener('click', event => {
+                selectTab(controls.indexOf(button));
+            });
+        }
+
+        selectTab(0);
+
+        return el('div', {class: 'editor-form-tab-container'}, [
+            el('div', {class: 'editor-form-tab-controls'}, controls),
+            el('div', {class: 'editor-form-tab-contents'}, contents),
+        ]);
+    }
 }
\ No newline at end of file
index 17e4af97bb0dcc64930bf6f4dd0d876ed2f89970..1e52ad6a9b29703c0ccd965d118d1ff2acce8db6 100644 (file)
@@ -398,6 +398,45 @@ textarea.editor-form-field-input {
     box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.1);
   }
 }
+.editor-form-tab-container {
+  display: flex;
+  flex-direction: row;
+  gap: 2rem;
+}
+.editor-form-tab-controls {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  gap: .25rem;
+}
+.editor-form-tab-control {
+  font-weight: bold;
+  font-size: 14px;
+  color: #444;
+  border-bottom: 2px solid transparent;
+  position: relative;
+  cursor: pointer;
+  padding: .25rem .5rem;
+  text-align: start;
+  &[aria-selected="true"] {
+    border-color: var(--editor-color-primary);
+    color: var(--editor-color-primary);
+  }
+  &[aria-selected="true"]:after, &:hover:after {
+    background-color: var(--editor-color-primary);
+    opacity: .15;
+    content: '';
+    display: block;
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+  }
+}
+.editor-form-tab-contents {
+  width: 360px;
+}
 
 // Editor theme styles
 .editor-theme-bold {