diff --git a/lang/en/editor.php b/lang/en/editor.php index a61b46042..752c6f3f7 100644 --- a/lang/en/editor.php +++ b/lang/en/editor.php @@ -13,6 +13,7 @@ return [ 'cancel' => 'Cancel', 'save' => 'Save', 'close' => 'Close', + 'apply' => 'Apply', 'undo' => 'Undo', 'redo' => 'Redo', 'left' => 'Left', @@ -147,6 +148,7 @@ return [ 'url' => 'URL', 'text_to_display' => 'Text to display', 'title' => 'Title', + 'browse_links' => 'Browse links', 'open_link' => 'Open link', 'open_link_in' => 'Open link in...', 'open_link_current' => 'Current window', diff --git a/resources/icons/editor/color-display.svg b/resources/icons/editor/color-display.svg new file mode 100644 index 000000000..86be9a7bf --- /dev/null +++ b/resources/icons/editor/color-display.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/resources/icons/editor/color-select.svg b/resources/icons/editor/color-select.svg new file mode 100644 index 000000000..cef686655 --- /dev/null +++ b/resources/icons/editor/color-select.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts index 0f1c0a5d3..f995237a0 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalCommands.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalCommands.ts @@ -8,7 +8,6 @@ import type { BaseSelection, - ElementFormatType, LexicalCommand, LexicalNode, TextFormatType, @@ -91,8 +90,6 @@ export const OUTDENT_CONTENT_COMMAND: LexicalCommand = createCommand( ); export const DROP_COMMAND: LexicalCommand = createCommand('DROP_COMMAND'); -export const FORMAT_ELEMENT_COMMAND: LexicalCommand = - createCommand('FORMAT_ELEMENT_COMMAND'); export const DRAGSTART_COMMAND: LexicalCommand = createCommand('DRAGSTART_COMMAND'); export const DRAGOVER_COMMAND: LexicalCommand = diff --git a/resources/js/wysiwyg/lexical/core/LexicalConstants.ts b/resources/js/wysiwyg/lexical/core/LexicalConstants.ts index 82461e74d..55668f1e4 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalConstants.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalConstants.ts @@ -6,7 +6,6 @@ * */ -import type {ElementFormatType} from './nodes/LexicalElementNode'; import type { TextDetailType, TextFormatType, @@ -111,27 +110,6 @@ export const DETAIL_TYPE_TO_DETAIL: Record = { unmergeable: IS_UNMERGEABLE, }; -export const ELEMENT_TYPE_TO_FORMAT: Record< - Exclude, - number -> = { - center: IS_ALIGN_CENTER, - end: IS_ALIGN_END, - justify: IS_ALIGN_JUSTIFY, - left: IS_ALIGN_LEFT, - right: IS_ALIGN_RIGHT, - start: IS_ALIGN_START, -}; - -export const ELEMENT_FORMAT_TO_TYPE: Record = { - [IS_ALIGN_CENTER]: 'center', - [IS_ALIGN_END]: 'end', - [IS_ALIGN_JUSTIFY]: 'justify', - [IS_ALIGN_LEFT]: 'left', - [IS_ALIGN_RIGHT]: 'right', - [IS_ALIGN_START]: 'start', -}; - export const TEXT_MODE_TO_TYPE: Record = { normal: IS_NORMAL, segmented: IS_SEGMENTED, diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index a6c9b6023..7306e6bca 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -146,6 +146,12 @@ type NodeName = string; * Output for a DOM conversion. * Node can be set to 'ignore' to ignore the conversion and handling of the DOMNode * including all its children. + * + * You can specify a function to run for each converted child (forChild) or on all + * the child nodes after the conversion is complete (after). + * The key difference here is that forChild runs for every deeply nested child node + * of the current node, whereas after will run only once after the + * transformation of the node and all its children is complete. */ export type DOMConversionOutput = { after?: (childLexicalNodes: Array) => Array; @@ -1165,6 +1171,16 @@ export class LexicalNode { markDirty(): void { this.getWritable(); } + + /** + * Insert the DOM of this node into that of the parent. + * Allows this node to implement custom DOM attachment logic. + * Boolean result indicates if the insertion was handled by the function. + * A true return value prevents default insertion logic from taking place. + */ + insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean { + return false; + } } function errorOnTypeKlassMismatch( diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts index fccf1ae23..297e96ce0 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts @@ -171,16 +171,21 @@ function $createNode( } if (parentDOM !== null) { - if (insertDOM != null) { - parentDOM.insertBefore(dom, insertDOM); - } else { - // @ts-expect-error: internal field - const possibleLineBreak = parentDOM.__lexicalLineBreak; - if (possibleLineBreak != null) { - parentDOM.insertBefore(dom, possibleLineBreak); + const inserted = node?.insertDOMIntoParent(dom, parentDOM); + + if (!inserted) { + if (insertDOM != null) { + parentDOM.insertBefore(dom, insertDOM); } else { - parentDOM.appendChild(dom); + // @ts-expect-error: internal field + const possibleLineBreak = parentDOM.__lexicalLineBreak; + + if (possibleLineBreak != null) { + parentDOM.insertBefore(dom, possibleLineBreak); + } else { + parentDOM.appendChild(dom); + } } } } diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index b13bba697..d54a64ce8 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -37,8 +37,6 @@ import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {EditorUiContext} from "../../../../ui/framework/core"; import {EditorUIManager} from "../../../../ui/framework/manager"; -import {turtle} from "@codemirror/legacy-modes/mode/turtle"; - type TestEnv = { readonly container: HTMLDivElement; @@ -47,6 +45,9 @@ type TestEnv = { readonly innerHTML: string; }; +/** + * @deprecated - Consider using `createTestContext` instead within the test case. + */ export function initializeUnitTest( runTests: (testEnv: TestEnv) => void, editorConfig: CreateEditorArgs = {namespace: 'test', theme: {}}, @@ -795,6 +796,30 @@ export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShap expect(shape.children).toMatchObject(expected); } +/** + * Expect a given prop within the JSON editor state structure to be the given value. + * Uses dot notation for the provided `propPath`. Example: + * 0.5.cat => First child, Sixth child, cat property + */ +export function expectEditorStateJSONPropToEqual(editor: LexicalEditor, propPath: string, expected: any) { + let currentItem: any = editor.getEditorState().toJSON().root; + let currentPath = []; + const pathParts = propPath.split('.'); + + for (const part of pathParts) { + currentPath.push(part); + const childAccess = Number.isInteger(Number(part)) && Array.isArray(currentItem.children); + const target = childAccess ? currentItem.children : currentItem; + + if (typeof target[part] === 'undefined') { + throw new Error(`Could not resolve editor state at path ${currentPath.join('.')}`) + } + currentItem = target[part]; + } + + expect(currentItem).toBe(expected); +} + function formatHtml(s: string): string { return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); } diff --git a/resources/js/wysiwyg/lexical/core/index.ts b/resources/js/wysiwyg/lexical/core/index.ts index 5ef926b5a..92cb4a1ca 100644 --- a/resources/js/wysiwyg/lexical/core/index.ts +++ b/resources/js/wysiwyg/lexical/core/index.ts @@ -49,15 +49,12 @@ export type { } from './LexicalNode'; export type { BaseSelection, - ElementPointType as ElementPoint, NodeSelection, Point, PointType, RangeSelection, - TextPointType as TextPoint, } from './LexicalSelection'; export type { - ElementFormatType, SerializedElementNode, } from './nodes/LexicalElementNode'; export type {SerializedRootNode} from './nodes/LexicalRootNode'; @@ -87,7 +84,6 @@ export { DRAGSTART_COMMAND, DROP_COMMAND, FOCUS_COMMAND, - FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, INDENT_CONTENT_COMMAND, INSERT_LINE_BREAK_COMMAND, diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts index 9624af67e..9ad508411 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts @@ -46,15 +46,6 @@ export type SerializedElementNode< SerializedLexicalNode >; -export type ElementFormatType = - | 'left' - | 'start' - | 'center' - | 'right' - | 'end' - | 'justify' - | ''; - // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export interface ElementNode { getTopLevelElement(): ElementNode | null; diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts index 4a3a48950..7f1b4f305 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalTextNode.ts @@ -1314,6 +1314,11 @@ const nodeNameToTextFormat: Record = { function convertTextFormatElement(domNode: HTMLElement): DOMConversionOutput { const format = nodeNameToTextFormat[domNode.nodeName.toLowerCase()]; + + if (format === 'code' && domNode.closest('pre')) { + return {node: null}; + } + if (format === undefined) { return {node: null}; } diff --git a/resources/js/wysiwyg/lexical/core/shared/invariant.ts b/resources/js/wysiwyg/lexical/core/shared/invariant.ts index 0e73848ba..8e008c11c 100644 --- a/resources/js/wysiwyg/lexical/core/shared/invariant.ts +++ b/resources/js/wysiwyg/lexical/core/shared/invariant.ts @@ -18,9 +18,9 @@ export default function invariant( return; } - throw new Error( - 'Internal Lexical error: invariant() is meant to be replaced at compile ' + - 'time. There is no runtime version. Error: ' + - message, - ); + for (const arg of args) { + message = (message || '').replace('%s', arg); + } + + throw new Error(message); } diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts index 5c3cb6cce..5018e10b4 100644 --- a/resources/js/wysiwyg/lexical/html/index.ts +++ b/resources/js/wysiwyg/lexical/html/index.ts @@ -11,7 +11,6 @@ import type { DOMChildConversion, DOMConversion, DOMConversionFn, - ElementFormatType, LexicalEditor, LexicalNode, } from 'lexical'; @@ -58,6 +57,7 @@ export function $generateNodesFromDOM( } } } + $unwrapArtificalNodes(allArtificialNodes); return lexicalNodes; @@ -324,8 +324,6 @@ function wrapContinuousInlines( nodes: Array, createWrapperFn: () => ElementNode, ): Array { - const textAlign = (domNode as HTMLElement).style - .textAlign as ElementFormatType; const out: Array = []; let continuousInlines: Array = []; // wrap contiguous inline child nodes in para diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts index cbe691848..49ba7754c 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts @@ -145,7 +145,14 @@ export class CodeBlockNode extends DecoratorNode { node.setId(element.id); } - return { node }; + return { + node, + after(childNodes): LexicalNode[] { + // Remove any child nodes that may get parsed since we're manually + // controlling the code contents. + return []; + }, + }; }, priority: 3, }; diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts index a675665ac..81fb96a93 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts @@ -16,6 +16,7 @@ import { } from "lexical/nodes/common"; import {$selectSingleNode} from "../../utils/selection"; import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; +import * as url from "node:url"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeSource = { @@ -343,11 +344,55 @@ export function $createMediaNodeFromHtml(html: string): MediaNode | null { return domElementToNode(tag as MediaNodeTag, el); } +interface UrlPattern { + readonly regex: RegExp; + readonly w: number; + readonly h: number; + readonly url: string; +} + +/** + * These patterns originate from the tinymce/tinymce project. + * https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts + * License: MIT Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc. + * License Link: https://github.com/tinymce/tinymce/blob/584a150679669859a528828e5d2910a083b1d911/LICENSE.TXT + */ +const urlPatterns: UrlPattern[] = [ + { + regex: /.*?youtu\.be\/([\w\-_\?&=.]+)/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$1', + }, + { + regex: /.*youtube\.com(.+)v=([^&]+)(&([a-z0-9&=\-_]+))?.*/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$2?$4', + }, + { + regex: /.*youtube.com\/embed\/([a-z0-9\?&=\-_]+).*/i, + w: 560, h: 314, + url: 'https://www.youtube.com/embed/$1', + }, +]; + const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov']; const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm']; const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx', '']; export function $createMediaNodeFromSrc(src: string): MediaNode { + + for (const pattern of urlPatterns) { + const match = src.match(pattern.regex); + if (match) { + const newSrc = src.replace(pattern.regex, pattern.url); + const node = new MediaNode('iframe'); + node.setSrc(newSrc); + node.setHeight(pattern.h); + node.setWidth(pattern.w); + return node; + } + } + let nodeTag: MediaNodeTag = 'iframe'; const srcEnd = src.split('?')[0].split('/').pop() || ''; const srcEndSplit = srcEnd.split('.'); @@ -360,7 +405,9 @@ export function $createMediaNodeFromSrc(src: string): MediaNode { nodeTag = 'embed'; } - return new MediaNode(nodeTag); + const node = new MediaNode(nodeTag); + node.setSrc(src); + return node; } export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode { diff --git a/resources/js/wysiwyg/lexical/rich-text/index.ts b/resources/js/wysiwyg/lexical/rich-text/index.ts index c585c028a..477fdd781 100644 --- a/resources/js/wysiwyg/lexical/rich-text/index.ts +++ b/resources/js/wysiwyg/lexical/rich-text/index.ts @@ -8,7 +8,6 @@ import type { CommandPayloadType, - ElementFormatType, LexicalCommand, LexicalEditor, PasteCommandType, @@ -44,7 +43,6 @@ import { DRAGSTART_COMMAND, DROP_COMMAND, ElementNode, - FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, INSERT_LINE_BREAK_COMMAND, INSERT_PARAGRAPH_COMMAND, @@ -285,25 +283,6 @@ export function registerRichText(editor: LexicalEditor): () => void { }, COMMAND_PRIORITY_EDITOR, ), - editor.registerCommand( - FORMAT_ELEMENT_COMMAND, - (format) => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) { - return false; - } - const nodes = selection.getNodes(); - for (const node of nodes) { - const element = $findMatchingParent( - node, - (parentNode): parentNode is ElementNode => - $isElementNode(parentNode) && !parentNode.isInline(), - ); - } - return true; - }, - COMMAND_PRIORITY_EDITOR, - ), editor.registerCommand( INSERT_LINE_BREAK_COMMAND, (selectStart) => { diff --git a/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts new file mode 100644 index 000000000..d9d83562c --- /dev/null +++ b/resources/js/wysiwyg/lexical/table/LexicalCaptionNode.ts @@ -0,0 +1,92 @@ +import { + $createTextNode, + DOMConversionMap, + DOMExportOutput, + EditorConfig, + ElementNode, + LexicalEditor, + LexicalNode, + SerializedElementNode +} from "lexical"; +import {TableNode} from "@lexical/table/LexicalTableNode"; + + +export class CaptionNode extends ElementNode { + static getType(): string { + return 'caption'; + } + + static clone(node: CaptionNode): CaptionNode { + return new CaptionNode(node.__key); + } + + createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement { + return document.createElement('caption'); + } + + updateDOM(_prevNode: unknown, _dom: HTMLElement, _config: EditorConfig): boolean { + return false; + } + + isParentRequired(): true { + return true; + } + + canBeEmpty(): boolean { + return false; + } + + exportJSON(): SerializedElementNode { + return { + ...super.exportJSON(), + type: 'caption', + version: 1, + }; + } + + insertDOMIntoParent(nodeDOM: HTMLElement, parentDOM: HTMLElement): boolean { + parentDOM.insertBefore(nodeDOM, parentDOM.firstChild); + return true; + } + + static importJSON(serializedNode: SerializedElementNode): CaptionNode { + return $createCaptionNode(); + } + + static importDOM(): DOMConversionMap | null { + return { + caption: (node: Node) => ({ + conversion(domNode: Node) { + return { + node: $createCaptionNode(), + } + }, + priority: 0, + }), + }; + } +} + +export function $createCaptionNode(): CaptionNode { + return new CaptionNode(); +} + +export function $isCaptionNode(node: LexicalNode | null | undefined): node is CaptionNode { + return node instanceof CaptionNode; +} + +export function $tableHasCaption(table: TableNode): boolean { + for (const child of table.getChildren()) { + if ($isCaptionNode(child)) { + return true; + } + } + return false; +} + +export function $addCaptionToTable(table: TableNode, text: string = ''): void { + const caption = $createCaptionNode(); + const textNode = $createTextNode(text || ' '); + caption.append(textNode); + table.append(caption); +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts index 9443747a6..a10361475 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -139,6 +139,8 @@ export class TableNode extends CommonBlockNode { for (const child of Array.from(tableElement.children)) { if (child.nodeName === 'TR') { tBody.append(child); + } else if (child.nodeName === 'CAPTION') { + newElement.insertBefore(child, newElement.firstChild); } else { newElement.append(child); } diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index 8a47f322d..c1db0f086 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -18,6 +18,7 @@ import {EditorUiContext} from "./ui/framework/core"; import {MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; +import {CaptionNode} from "@lexical/table/LexicalCaptionNode"; /** * Load the nodes for lexical. @@ -32,6 +33,7 @@ export function getNodesForPageEditor(): (KlassConstructor | TableNode, TableRowNode, TableCellNode, + CaptionNode, ImageNode, // TODO - Alignment HorizontalRuleNode, DetailsNode, diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts index 30dc92565..add61c495 100644 --- a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts +++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts @@ -1,91 +1,76 @@ -import {initializeUnitTest} from "lexical/__tests__/utils"; -import {SerializedLinkNode} from "@lexical/link"; +import { + createTestContext, + dispatchKeydownEventForNode, expectEditorStateJSONPropToEqual, + expectNodeShapeToMatch +} from "lexical/__tests__/utils"; import { $getRoot, ParagraphNode, - SerializedParagraphNode, - SerializedTextNode, TextNode } from "lexical"; import {registerAutoLinks} from "../auto-links"; describe('Auto-link service tests', () => { - initializeUnitTest((testEnv) => { + test('space after link in text', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; - test('space after link in text', async () => { - const {editor} = testEnv; + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); - registerAutoLinks(editor); - let pNode!: ParagraphNode; - - editor.update(() => { - pNode = new ParagraphNode(); - const text = new TextNode('Some https://example.com?test=true text'); - pNode.append(text); - $getRoot().append(pNode); - - text.select(34, 34); - }); - - editor.commitUpdates(); - - const pDomEl = editor.getElementByKey(pNode.getKey()); - const event = new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - key: ' ', - keyCode: 62, - }); - pDomEl?.dispatchEvent(event); - - editor.commitUpdates(); - - const paragraph = editor!.getEditorState().toJSON().root - .children[0] as SerializedParagraphNode; - expect(paragraph.children[1].type).toBe('link'); - - const link = paragraph.children[1] as SerializedLinkNode; - expect(link.url).toBe('https://example.com?test=true'); - const linkText = link.children[0] as SerializedTextNode; - expect(linkText.text).toBe('https://example.com?test=true'); + text.select(34, 34); }); - test('enter after link in text', async () => { - const {editor} = testEnv; + dispatchKeydownEventForNode(pNode, editor, ' '); - registerAutoLinks(editor); - let pNode!: ParagraphNode; + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true'); + }); - editor.update(() => { - pNode = new ParagraphNode(); - const text = new TextNode('Some https://example.com?test=true text'); - pNode.append(text); - $getRoot().append(pNode); + test('space after link at end of line', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; - text.select(34, 34); - }); + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true'); + pNode.append(text); + $getRoot().append(pNode); - editor.commitUpdates(); - - const pDomEl = editor.getElementByKey(pNode.getKey()); - const event = new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - key: 'Enter', - keyCode: 66, - }); - pDomEl?.dispatchEvent(event); - - editor.commitUpdates(); - - const paragraph = editor!.getEditorState().toJSON().root - .children[0] as SerializedParagraphNode; - expect(paragraph.children[1].type).toBe('link'); - - const link = paragraph.children[1] as SerializedLinkNode; - expect(link.url).toBe('https://example.com?test=true'); - const linkText = link.children[0] as SerializedTextNode; - expect(linkText.text).toBe('https://example.com?test=true'); + text.selectEnd(); }); + + dispatchKeydownEventForNode(pNode, editor, ' '); + + expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [ + {text: 'Some '}, + {type: 'link', children: [{text: 'https://example.com?test=true'}]} + ]}]); + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + }); + + test('enter after link in text', async () => { + const {editor} = createTestContext(); + registerAutoLinks(editor); + let pNode!: ParagraphNode; + + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); + + text.select(34, 34); + }); + + dispatchKeydownEventForNode(pNode, editor, 'Enter'); + + expectEditorStateJSONPropToEqual(editor, '0.1.url', 'https://example.com?test=true'); + expectEditorStateJSONPropToEqual(editor, '0.1.0.text', 'https://example.com?test=true'); }); }); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts index 1c3b1c730..62cd45994 100644 --- a/resources/js/wysiwyg/services/auto-links.ts +++ b/resources/js/wysiwyg/services/auto-links.ts @@ -43,7 +43,7 @@ function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, edit linkNode.append(new TextNode(textSegment)); const splits = node.splitText(startIndex, cursorPoint); - const targetIndex = splits.length === 3 ? 1 : 0; + const targetIndex = startIndex > 0 ? 1 : 0; const targetText = splits[targetIndex]; if (targetText) { targetText.replace(linkNode); diff --git a/resources/js/wysiwyg/testing.md b/resources/js/wysiwyg/testing.md new file mode 100644 index 000000000..7b272c606 --- /dev/null +++ b/resources/js/wysiwyg/testing.md @@ -0,0 +1,55 @@ +# Testing Guidance + +This is testing guidance specific for this Lexical-based WYSIWYG editor. +There is a lot of pre-existing test code carried over form the fork of lexical, but since there we've added a range of helpers and altered how testing can be done to make things a bit simpler and aligned with how we run tests. + +This document is an attempt to document the new best options for added tests with an aim for standardisation on these approaches going forward. + +## Utils Location + +Most core test utils can be found in the file at path: resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts + +## Test Example + +This is an example of a typical test using the common modern utilities to help perform actions or assertions. Comments are for this example only, and are not expected in actual test files. + +```ts +import { + createTestContext, + dispatchKeydownEventForNode, + expectEditorStateJSONPropToEqual, + expectNodeShapeToMatch +} from "lexical/__tests__/utils"; +import { + $getRoot, + ParagraphNode, + TextNode +} from "lexical"; + +describe('A specific service or file or function', () => { + test('it does thing', async () => { + // Create the editor context and get an editor reference + const {editor} = createTestContext(); + + // Run an action within the editor. + let pNode: ParagraphNode; + editor.updateAndCommit(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Hello!'); + pNode.append(text); + $getRoot().append(pNode); + }); + + // Dispatch key events via the DOM + dispatchKeydownEventForNode(pNode!, editor, ' '); + + // Check the shape (and text) of the resulting state + expectNodeShapeToMatch(editor, [{type: 'paragraph', children: [ + {text: 'Hello!'}, + ]}]); + + // Check specific props in the resulting JSON state + expectEditorStateJSONPropToEqual(editor, '0.0.text', 'Hello!'); + }); +}); +``` \ No newline at end of file diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md deleted file mode 100644 index 817a235a7..000000000 --- a/resources/js/wysiwyg/todo.md +++ /dev/null @@ -1,27 +0,0 @@ -# Lexical based editor todo - -## In progress - -Reorg - - Merge custom nodes into original nodes - - Reduce down to use CommonBlockNode where possible - - Remove existing formatType/ElementFormatType references (replaced with alignment). - - Remove existing indent references (replaced with inset). - -## Main Todo - -// - -## Secondary Todo - -- Color picker support in table form color fields -- Color picker for color controls -- Table caption text support -- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) -- Deep check of translation coverage -- About button & view -- Mobile display and handling - -## Bugs - -// \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts index 5e3200539..6c22d3faa 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -57,7 +57,7 @@ export const redo: EditorButtonDefinition = { export const source: EditorButtonDefinition = { - label: 'Source', + label: 'Source code', icon: sourceIcon, async action(context: EditorUiContext) { const modal = context.manager.createModal('source'); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts index c3726acf0..c5b7ad29a 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/inline-formats.ts @@ -12,6 +12,8 @@ import subscriptIcon from "@icons/editor/subscript.svg"; import codeIcon from "@icons/editor/code.svg"; import formatClearIcon from "@icons/editor/format-clear.svg"; import {$selectionContainsTextFormat} from "../../../utils/selection"; +import {$patchStyleText} from "@lexical/selection"; +import {context} from "esbuild"; function buildFormatButton(label: string, format: TextFormatType, icon: string): EditorButtonDefinition { return { @@ -32,6 +34,18 @@ export const underline: EditorButtonDefinition = buildFormatButton('Underline', export const textColor: EditorBasicButtonDefinition = {label: 'Text color', icon: textColorIcon}; export const highlightColor: EditorBasicButtonDefinition = {label: 'Background color', icon: highlightIcon}; +function colorAction(context: EditorUiContext, property: string, color: string): void { + context.editor.update(() => { + const selection = $getSelection(); + if (selection) { + $patchStyleText(selection, {[property]: color || null}); + } + }); +} + +export const textColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color); +export const highlightColorAction = (color: string, context: EditorUiContext) => colorAction(context, 'color', color); + export const strikethrough: EditorButtonDefinition = buildFormatButton('Strikethrough', 'strikethrough', strikethroughIcon); export const superscript: EditorButtonDefinition = buildFormatButton('Superscript', 'superscript', superscriptIcon); export const subscript: EditorButtonDefinition = buildFormatButton('Subscript', 'subscript', subscriptIcon); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 6612c0dc4..4eb4c5a4e 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -32,7 +32,7 @@ import { } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; -import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects"; +import {$showDetailsForm, $showImageForm, $showLinkForm, $showMediaForm} from "../forms/objects"; import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { @@ -165,27 +165,14 @@ export const diagramManager: EditorButtonDefinition = { }; export const media: EditorButtonDefinition = { - label: 'Insert/edit Media', + label: 'Insert/edit media', icon: mediaIcon, action(context: EditorUiContext) { - const mediaModal = context.manager.createModal('media'); - context.editor.getEditorState().read(() => { const selection = $getSelection(); const selectedNode = $getNodeFromSelection(selection, $isMediaNode) as MediaNode | null; - let formDefaults = {}; - if (selectedNode) { - const nodeAttrs = selectedNode.getAttributes(); - formDefaults = { - src: nodeAttrs.src || nodeAttrs.data || '', - width: nodeAttrs.width, - height: nodeAttrs.height, - embed: '', - } - } - - mediaModal.show(formDefaults); + $showMediaForm(selectedNode, context); }); }, isActive(selection: BaseSelection | null): boolean { diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 21d333c3a..0effdc171 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -186,6 +186,23 @@ export const link: EditorFormDefinition = { ], }; +export function $showMediaForm(media: MediaNode|null, context: EditorUiContext): void { + const mediaModal = context.manager.createModal('media'); + + let formDefaults = {}; + if (media) { + const nodeAttrs = media.getAttributes(); + formDefaults = { + src: nodeAttrs.src || nodeAttrs.data || '', + width: nodeAttrs.width, + height: nodeAttrs.height, + embed: '', + } + } + + mediaModal.show(formDefaults); +} + export const media: EditorFormDefinition = { submitText: 'Save', async action(formData, context: EditorUiContext) { @@ -215,12 +232,19 @@ export const media: EditorFormDefinition = { const height = (formData.get('height') || '').toString().trim(); const width = (formData.get('width') || '').toString().trim(); - const updateNode = selectedNode || $createMediaNodeFromSrc(src); - updateNode.setSrc(src); - updateNode.setWidthAndHeight(width, height); - if (!selectedNode) { - $insertNodes([updateNode]); + // Update existing + if (selectedNode) { + selectedNode.setSrc(src); + selectedNode.setWidthAndHeight(width, height); + return; } + + // Insert new + const node = $createMediaNodeFromSrc(src); + if (width || height) { + node.setWidthAndHeight(width, height); + } + $insertNodes([node]); }); return true; diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 63fa24c80..5b484310d 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -1,6 +1,6 @@ import { EditorFormDefinition, - EditorFormFieldDefinition, + EditorFormFieldDefinition, EditorFormFields, EditorFormTabs, EditorSelectFormFieldDefinition } from "../../framework/forms"; @@ -17,6 +17,8 @@ import { import {formatSizeValue} from "../../../utils/dom"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {CommonBlockAlignment} from "lexical/nodes/common"; +import {colorFieldBuilder} from "../../framework/blocks/color-field"; +import {$addCaptionToTable, $isCaptionNode, $tableHasCaption} from "@lexical/table/LexicalCaptionNode"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -145,15 +147,15 @@ export const cellProperties: EditorFormDefinition = { } as EditorSelectFormFieldDefinition, ]; - const advancedFields: EditorFormFieldDefinition[] = [ + const advancedFields: EditorFormFields = [ { label: 'Border width', // inline-style: border-width name: 'border_width', type: 'text', }, borderStyleInput, // inline-style: border-style - borderColorInput, // inline-style: border-color - backgroundColorInput, // inline-style: background-color + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ]; return new EditorFormTabs([ @@ -210,14 +212,15 @@ export const rowProperties: EditorFormDefinition = { type: 'text', }, borderStyleInput, // style on tr: height - borderColorInput, // style on tr: height - backgroundColorInput, // style on tr: height + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ], }; export function $showTablePropertiesForm(table: TableNode, context: EditorUiContext): EditorFormModal { const styles = table.getStyles(); const modalForm = context.manager.createModal('table_properties'); + modalForm.show({ width: styles.get('width') || '', height: styles.get('height') || '', @@ -227,7 +230,7 @@ export function $showTablePropertiesForm(table: TableNode, context: EditorUiCont border_style: styles.get('border-style') || '', border_color: styles.get('border-color') || '', background_color: styles.get('background-color') || '', - // caption: '', TODO + caption: $tableHasCaption(table) ? 'true' : '', align: table.getAlignment(), }); return modalForm; @@ -264,7 +267,17 @@ export const tableProperties: EditorFormDefinition = { }); } - // TODO - cell caption + const showCaption = Boolean(formData.get('caption')?.toString() || ''); + const hasCaption = $tableHasCaption(table); + if (showCaption && !hasCaption) { + $addCaptionToTable(table, context.translate('Caption')); + } else if (!showCaption && hasCaption) { + for (const child of table.getChildren()) { + if ($isCaptionNode(child)) { + child.remove(); + } + } + } }); return true; }, @@ -298,17 +311,17 @@ export const tableProperties: EditorFormDefinition = { type: 'text', }, { - label: 'caption', // Caption element + label: 'Show caption', // Caption element name: 'caption', - type: 'text', // TODO - + type: 'checkbox', }, alignmentInput, // alignment class ]; - const advancedFields: EditorFormFieldDefinition[] = [ - borderStyleInput, // Style - border-style - borderColorInput, // Style - border-color - backgroundColorInput, // Style - background-color + const advancedFields: EditorFormFields = [ + borderStyleInput, + colorFieldBuilder(borderColorInput), + colorFieldBuilder(backgroundColorInput), ]; return new EditorFormTabs([ diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts index 61baa3c32..b09a7530f 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -44,11 +44,11 @@ import { } from "./buttons/block-formats"; import { bold, clearFormating, code, - highlightColor, + highlightColor, highlightColorAction, italic, strikethrough, subscript, superscript, - textColor, + textColor, textColorAction, underline } from "./buttons/inline-formats"; import { @@ -114,10 +114,10 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai new EditorButton(italic), new EditorButton(underline), new EditorDropdownButton({ button: new EditorColorButton(textColor, 'color') }, [ - new EditorColorPicker('color'), + new EditorColorPicker(textColorAction), ]), new EditorDropdownButton({button: new EditorColorButton(highlightColor, 'background-color')}, [ - new EditorColorPicker('background-color'), + new EditorColorPicker(highlightColorAction), ]), new EditorButton(strikethrough), new EditorButton(superscript), diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-field.ts b/resources/js/wysiwyg/ui/framework/blocks/color-field.ts new file mode 100644 index 000000000..8c8f167d9 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/color-field.ts @@ -0,0 +1,56 @@ +import {EditorContainerUiElement, EditorUiBuilderDefinition, EditorUiContext} from "../core"; +import {EditorFormField, EditorFormFieldDefinition} from "../forms"; +import {EditorColorPicker} from "./color-picker"; +import {EditorDropdownButton} from "./dropdown-button"; + +import colorDisplayIcon from "@icons/editor/color-display.svg" + +export class EditorColorField extends EditorContainerUiElement { + protected input: EditorFormField; + protected pickerButton: EditorDropdownButton; + + constructor(input: EditorFormField) { + super([]); + + this.input = input; + + this.pickerButton = new EditorDropdownButton({ + button: { icon: colorDisplayIcon, label: 'Select color'} + }, [ + new EditorColorPicker(this.onColorSelect.bind(this)) + ]); + this.addChildren(this.pickerButton, this.input); + } + + protected buildDOM(): HTMLElement { + const dom = this.input.getDOMElement(); + dom.append(this.pickerButton.getDOMElement()); + dom.classList.add('editor-color-field-container'); + + const field = dom.querySelector('input') as HTMLInputElement; + field.addEventListener('change', () => { + this.setIconColor(field.value); + }); + + return dom; + } + + onColorSelect(color: string, context: EditorUiContext): void { + this.input.setValue(color); + } + + setIconColor(color: string) { + const icon = this.getDOMElement().querySelector('svg .editor-icon-color-display'); + if (icon) { + icon.setAttribute('fill', color || 'url(#pattern2)'); + } + } +} + +export function colorFieldBuilder(field: EditorFormFieldDefinition): EditorUiBuilderDefinition { + return { + build() { + return new EditorColorField(new EditorFormField(field)); + } + } +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts index b068fb4f0..8e62a0e5e 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/color-picker.ts @@ -1,9 +1,9 @@ -import {EditorUiElement} from "../core"; -import {$getSelection} from "lexical"; -import {$patchStyleText} from "@lexical/selection"; +import {EditorUiContext, EditorUiElement} from "../core"; import {el} from "../../../utils/dom"; import removeIcon from "@icons/editor/color-clear.svg"; +import selectIcon from "@icons/editor/color-select.svg"; +import {uniqueIdSmall} from "../../../../services/util"; const colorChoices = [ '#000000', @@ -34,18 +34,24 @@ const colorChoices = [ '#34495E', ]; +const storageKey = 'bs-lexical-custom-colors'; + +export type EditorColorPickerCallback = (color: string, context: EditorUiContext) => void; + export class EditorColorPicker extends EditorUiElement { - protected styleProperty: string; + protected callback: EditorColorPickerCallback; - constructor(styleProperty: string) { + constructor(callback: EditorColorPickerCallback) { super(); - this.styleProperty = styleProperty; + this.callback = callback; } buildDOM(): HTMLElement { + const id = uniqueIdSmall(); - const colorOptions = colorChoices.map(choice => { + const allChoices = [...colorChoices, ...this.getCustomColorChoices()]; + const colorOptions = allChoices.map(choice => { return el('div', { class: 'editor-color-select-option', style: `background-color: ${choice}`, @@ -57,11 +63,30 @@ export class EditorColorPicker extends EditorUiElement { const removeButton = el('div', { class: 'editor-color-select-option', 'data-color': '', - title: 'Clear color', + title: this.getContext().translate('Remove color'), }, []); removeButton.innerHTML = removeIcon; colorOptions.push(removeButton); + const selectButton = el('label', { + class: 'editor-color-select-option', + for: `color-select-${id}`, + 'data-color': '', + title: this.getContext().translate('Custom color'), + }, []); + selectButton.innerHTML = selectIcon; + colorOptions.push(selectButton); + + const input = el('input', {type: 'color', hidden: 'true', id: `color-select-${id}`}) as HTMLInputElement; + colorOptions.push(input); + input.addEventListener('change', e => { + if (input.value) { + this.storeCustomColorChoice(input.value); + this.setColor(input.value); + this.rebuildDOM(); + } + }); + const colorRows = []; for (let i = 0; i < colorOptions.length; i+=5) { const options = colorOptions.slice(i, i + 5); @@ -79,16 +104,33 @@ export class EditorColorPicker extends EditorUiElement { return wrapper; } + storeCustomColorChoice(color: string) { + if (colorChoices.includes(color)) { + return; + } + + const customColors: string[] = this.getCustomColorChoices(); + if (customColors.includes(color)) { + return; + } + + customColors.push(color); + window.localStorage.setItem(storageKey, JSON.stringify(customColors)); + } + + getCustomColorChoices(): string[] { + return JSON.parse(window.localStorage.getItem(storageKey) || '[]'); + } + onClick(event: MouseEvent) { const colorEl = (event.target as HTMLElement).closest('[data-color]') as HTMLElement; if (!colorEl) return; const color = colorEl.dataset.color as string; - this.getContext().editor.update(() => { - const selection = $getSelection(); - if (selection) { - $patchStyleText(selection, {[this.styleProperty]: color || null}); - } - }); + this.setColor(color); + } + + setColor(color: string) { + this.callback(color, this.getContext()); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts index f88b22c3f..880238a9a 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts @@ -44,7 +44,6 @@ export class LinkField extends EditorContainerUiElement { updateFormFromHeader(header: HeadingNode) { this.getHeaderIdAndText(header).then(({id, text}) => { - console.log('updating form', id, text); const modal = this.getContext().manager.getActiveModal('link'); if (modal) { modal.getForm().setValues({ @@ -60,7 +59,6 @@ export class LinkField extends EditorContainerUiElement { return new Promise((res) => { this.getContext().editor.update(() => { let id = header.getId(); - console.log('header', id, header.__id); if (!id) { id = 'header-' + uniqueIdSmall(); header.setId(id); diff --git a/resources/js/wysiwyg/ui/framework/core.ts b/resources/js/wysiwyg/ui/framework/core.ts index 3433b96e8..90ce4ebf9 100644 --- a/resources/js/wysiwyg/ui/framework/core.ts +++ b/resources/js/wysiwyg/ui/framework/core.ts @@ -53,6 +53,13 @@ export abstract class EditorUiElement { return this.dom; } + rebuildDOM(): HTMLElement { + const newDOM = this.buildDOM(); + this.dom?.replaceWith(newDOM); + this.dom = newDOM; + return this.dom; + } + trans(text: string) { return this.getContext().translate(text); } diff --git a/resources/js/wysiwyg/ui/framework/forms.ts b/resources/js/wysiwyg/ui/framework/forms.ts index 36371e302..08edb214e 100644 --- a/resources/js/wysiwyg/ui/framework/forms.ts +++ b/resources/js/wysiwyg/ui/framework/forms.ts @@ -11,7 +11,7 @@ import {el} from "../../utils/dom"; export interface EditorFormFieldDefinition { label: string; name: string; - type: 'text' | 'select' | 'textarea'; + type: 'text' | 'select' | 'textarea' | 'checkbox'; } export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition { @@ -19,15 +19,17 @@ export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefiniti valuesByLabel: Record } +export type EditorFormFields = (EditorFormFieldDefinition|EditorUiBuilderDefinition)[]; + interface EditorFormTabDefinition { label: string; - contents: EditorFormFieldDefinition[]; + contents: EditorFormFields; } export interface EditorFormDefinition { submitText: string; action: (formData: FormData, context: EditorUiContext) => Promise; - fields: (EditorFormFieldDefinition|EditorUiBuilderDefinition)[]; + fields: EditorFormFields; } export class EditorFormField extends EditorUiElement { @@ -40,7 +42,12 @@ export class EditorFormField extends EditorUiElement { setValue(value: string) { const input = this.getDOMElement().querySelector('input,select,textarea') as HTMLInputElement; - input.value = value; + if (this.definition.type === 'checkbox') { + input.checked = Boolean(value); + } else { + input.value = value; + } + input.dispatchEvent(new Event('change')); } getName(): string { @@ -58,6 +65,8 @@ export class EditorFormField extends EditorUiElement { input = el('select', {id, name: this.definition.name, class: 'editor-form-field-input'}, optionElems); } else if (this.definition.type === 'textarea') { input = el('textarea', {id, name: this.definition.name, class: 'editor-form-field-input'}); + } else if (this.definition.type === 'checkbox') { + input = el('input', {id, name: this.definition.name, type: 'checkbox', class: 'editor-form-field-input-checkbox', value: 'true'}); } else { input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'}); } @@ -155,11 +164,17 @@ export class EditorForm extends EditorContainerUiElement { export class EditorFormTab extends EditorContainerUiElement { protected definition: EditorFormTabDefinition; - protected fields: EditorFormField[]; + protected fields: EditorUiElement[]; protected id: string; constructor(definition: EditorFormTabDefinition) { - const fields = definition.contents.map(fieldDef => new EditorFormField(fieldDef)); + const fields = definition.contents.map(fieldDef => { + if (isUiBuilderDefinition(fieldDef)) { + return fieldDef.build(); + } + return new EditorFormField(fieldDef) + }); + super(fields); this.definition = definition; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 2446c1416..9f7694e85 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -649,6 +649,16 @@ textarea.editor-form-field-input { width: $inputWidth - 40px; } } +.editor-color-field-container { + position: relative; + input { + padding-left: 36px; + } + .editor-dropdown-menu-container { + position: absolute; + bottom: 0; + } +} // Editor theme styles .editor-theme-bold {