From e5b6d28bcaf78a08fab97e0ee0496650b2466569 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 7 Sep 2024 18:39:58 +0100 Subject: [PATCH] Lexical: Revamped image node resize method Changed from using a decorator to using a helper that watches for image selections to then display a resize helper. Also changes resizer to use a ghost and apply changes on end instead of continuosly during resize. --- resources/js/wysiwyg/index.ts | 2 + resources/js/wysiwyg/nodes/image.ts | 55 +++--- resources/js/wysiwyg/todo.md | 4 +- resources/js/wysiwyg/ui/decorators/image.ts | 132 -------------- .../wysiwyg/ui/defaults/buttons/alignments.ts | 16 +- .../ui/framework/helpers/image-resizer.ts | 167 ++++++++++++++++++ resources/js/wysiwyg/ui/framework/manager.ts | 19 +- resources/js/wysiwyg/ui/index.ts | 2 - resources/js/wysiwyg/utils/selection.ts | 20 ++- resources/sass/_editor.scss | 26 ++- 10 files changed, 251 insertions(+), 192 deletions(-) delete mode 100644 resources/js/wysiwyg/ui/decorators/image.ts create mode 100644 resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index fdcfa5b7e..c312919db 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -13,6 +13,7 @@ import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler" import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; import {el} from "./utils/dom"; import {registerShortcuts} from "./services/shortcuts"; +import {registerImageResizer} from "./ui/framework/helpers/image-resizer"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -55,6 +56,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerTableSelectionHandler(editor), registerTaskListHandler(editor, editArea), registerDropPasteHandling(context), + registerImageResizer(context), ); listenToCommonEvents(editor); diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/nodes/image.ts index 77c854b41..c9d11d871 100644 --- a/resources/js/wysiwyg/nodes/image.ts +++ b/resources/js/wysiwyg/nodes/image.ts @@ -1,16 +1,14 @@ import { - DecoratorNode, DOMConversion, DOMConversionMap, - DOMConversionOutput, + DOMConversionOutput, ElementNode, LexicalEditor, LexicalNode, - SerializedLexicalNode, Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; -import {EditorDecoratorAdapter} from "../ui/framework/decorator"; -import {el} from "../utils/dom"; import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common"; +import {$selectSingleNode} from "../utils/selection"; +import {SerializedElementNode} from "lexical/nodes/LexicalElementNode"; export interface ImageNodeOptions { alt?: string; @@ -24,9 +22,9 @@ export type SerializedImageNode = Spread<{ width: number; height: number; alignment: CommonBlockAlignment; -}, SerializedLexicalNode> +}, SerializedElementNode> -export class ImageNode extends DecoratorNode { +export class ImageNode extends ElementNode { __src: string = ''; __alt: string = ''; __width: number = 0; @@ -38,11 +36,13 @@ export class ImageNode extends DecoratorNode { } static clone(node: ImageNode): ImageNode { - return new ImageNode(node.__src, { + const newNode = new ImageNode(node.__src, { alt: node.__alt, width: node.__width, height: node.__height, }); + newNode.__alignment = node.__alignment; + return newNode; } constructor(src: string, options: ImageNodeOptions, key?: string) { @@ -113,13 +113,6 @@ export class ImageNode extends DecoratorNode { return true; } - decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter { - return { - type: 'image', - getNode: () => this, - }; - } - createDOM(_config: EditorConfig, _editor: LexicalEditor) { const element = document.createElement('img'); element.setAttribute('src', this.__src); @@ -138,49 +131,50 @@ export class ImageNode extends DecoratorNode { element.classList.add('align-' + this.__alignment); } - return el('span', {class: 'editor-image-wrap'}, [ - element, - ]); + element.addEventListener('click', e => { + _editor.update(() => { + $selectSingleNode(this); + }); + }); + + return element; } updateDOM(prevNode: ImageNode, dom: HTMLElement) { - const image = dom.querySelector('img'); - if (!image) return false; - if (prevNode.__src !== this.__src) { - image.setAttribute('src', this.__src); + dom.setAttribute('src', this.__src); } if (prevNode.__width !== this.__width) { if (this.__width) { - image.setAttribute('width', String(this.__width)); + dom.setAttribute('width', String(this.__width)); } else { - image.removeAttribute('width'); + dom.removeAttribute('width'); } } if (prevNode.__height !== this.__height) { if (this.__height) { - image.setAttribute('height', String(this.__height)); + dom.setAttribute('height', String(this.__height)); } else { - image.removeAttribute('height'); + dom.removeAttribute('height'); } } if (prevNode.__alt !== this.__alt) { if (this.__alt) { - image.setAttribute('alt', String(this.__alt)); + dom.setAttribute('alt', String(this.__alt)); } else { - image.removeAttribute('alt'); + dom.removeAttribute('alt'); } } if (prevNode.__alignment !== this.__alignment) { if (prevNode.__alignment) { - image.classList.remove('align-' + prevNode.__alignment); + dom.classList.remove('align-' + prevNode.__alignment); } if (this.__alignment) { - image.classList.add('align-' + this.__alignment); + dom.classList.add('align-' + this.__alignment); } } @@ -213,6 +207,7 @@ export class ImageNode extends DecoratorNode { exportJSON(): SerializedImageNode { return { + ...super.exportJSON(), type: 'image', version: 1, src: this.__src, diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 795f7ab9c..064d65e4c 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,7 +6,6 @@ ## Main Todo -- 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) - Mac: Shortcut support via command. @@ -14,12 +13,11 @@ - Color picker support in table form color fields - 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) ## Bugs -- Image alignment in editor dodgy due to wrapper. - Can't select iframe embeds by themselves. (click enters iframe) -- Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node. - Removing link around image via button deletes image, not just link - `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect. - Template drag/drop not handled when outside core editor area (ignored in margin area). diff --git a/resources/js/wysiwyg/ui/decorators/image.ts b/resources/js/wysiwyg/ui/decorators/image.ts deleted file mode 100644 index d110bc499..000000000 --- a/resources/js/wysiwyg/ui/decorators/image.ts +++ /dev/null @@ -1,132 +0,0 @@ -import {EditorDecorator} from "../framework/decorator"; -import {$createNodeSelection, $setSelection} from "lexical"; -import {EditorUiContext} from "../framework/core"; -import {ImageNode} from "../../nodes/image"; -import {MouseDragTracker, MouseDragTrackerDistance} from "../framework/helpers/mouse-drag-tracker"; -import {$selectSingleNode} from "../../utils/selection"; -import {el} from "../../utils/dom"; - - -export class ImageDecorator extends EditorDecorator { - protected dom: HTMLElement|null = null; - protected dragLastMouseUp: number = 0; - - buildDOM(context: EditorUiContext) { - let handleElems: HTMLElement[] = []; - const decorateEl = el('div', { - class: 'editor-image-decorator', - }, []); - let selected = false; - let tracker: MouseDragTracker|null = null; - - const windowClick = (event: MouseEvent) => { - if (!decorateEl.contains(event.target as Node) && (Date.now() - this.dragLastMouseUp > 100)) { - unselect(); - } - }; - - const select = () => { - if (selected) { - return; - } - - selected = true; - decorateEl.classList.add('selected'); - window.addEventListener('click', windowClick); - - const handleClasses = ['nw', 'ne', 'se', 'sw']; - handleElems = handleClasses.map(c => { - return el('div', {class: `editor-image-decorator-handle ${c}`}); - }); - decorateEl.append(...handleElems); - tracker = this.setupTracker(decorateEl, context); - - context.editor.update(() => { - $selectSingleNode(this.getNode()); - }); - }; - - const unselect = () => { - selected = false; - decorateEl.classList.remove('selected'); - window.removeEventListener('click', windowClick); - tracker?.teardown(); - for (const el of handleElems) { - el.remove(); - } - }; - - decorateEl.addEventListener('click', (event) => { - select(); - }); - - return decorateEl; - } - - render(context: EditorUiContext): HTMLElement { - if (this.dom) { - return this.dom; - } - - this.dom = this.buildDOM(context); - return this.dom; - } - - setupTracker(container: HTMLElement, context: EditorUiContext): MouseDragTracker { - let startingWidth: number = 0; - let startingHeight: number = 0; - let startingRatio: number = 0; - let hasHeight = false; - let firstChange = true; - let node: ImageNode = this.getNode() as ImageNode; - let _this = this; - let flipXChange: boolean = false; - let flipYChange: boolean = false; - - return new MouseDragTracker(container, '.editor-image-decorator-handle', { - down(event: MouseEvent, handle: HTMLElement) { - context.editor.getEditorState().read(() => { - startingWidth = node.getWidth() || startingWidth; - startingHeight = node.getHeight() || startingHeight; - if (node.getHeight()) { - hasHeight = true; - } - startingRatio = startingWidth / startingHeight; - }); - - flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw'); - flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne'); - }, - move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) { - let xChange = distance.x; - if (flipXChange) { - xChange = 0 - xChange; - } - let yChange = distance.y; - if (flipYChange) { - yChange = 0 - yChange; - } - const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2)); - const increase = xChange + yChange > 0; - const directedChange = increase ? balancedChange : 0-balancedChange; - const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); - let newHeight = 0; - if (hasHeight) { - newHeight = newWidth * startingRatio; - } - - const updateOptions = firstChange ? {} : {tag: 'history-merge'}; - context.editor.update(() => { - const node = _this.getNode() as ImageNode; - node.setWidth(newWidth); - node.setHeight(newHeight); - }, updateOptions); - firstChange = false; - }, - up() { - _this.dragLastMouseUp = Date.now(); - } - }); - } - -} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index 75440aed8..329b11956 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -1,4 +1,4 @@ -import {BaseSelection, LexicalEditor} from "lexical"; +import {$isElementNode, BaseSelection, LexicalEditor} from "lexical"; import {EditorButtonDefinition} from "../../framework/buttons"; import alignLeftIcon from "@icons/editor/align-left.svg"; import {EditorUiContext} from "../../framework/core"; @@ -7,8 +7,7 @@ import alignRightIcon from "@icons/editor/align-right.svg"; import alignJustifyIcon from "@icons/editor/align-justify.svg"; import { $getBlockElementNodesInSelection, - $getDecoratorNodesInSelection, - $selectionContainsAlignment, getLastSelection + $selectionContainsAlignment, $selectSingleNode, $toggleSelection, getLastSelection } from "../../../utils/selection"; import {CommonBlockAlignment} from "../../../nodes/_common"; import {nodeHasAlignment} from "../../../utils/nodes"; @@ -17,12 +16,12 @@ import {nodeHasAlignment} from "../../../utils/nodes"; function setAlignmentForSection(editor: LexicalEditor, alignment: CommonBlockAlignment): void { const selection = getLastSelection(editor); const selectionNodes = selection?.getNodes() || []; - const decorators = $getDecoratorNodesInSelection(selection); - // Handle decorator node selection alignment - if (selectionNodes.length === 1 && decorators.length === 1 && nodeHasAlignment(decorators[0])) { - decorators[0].setAlignment(alignment); - console.log('setting for decorator!'); + // Handle inline node selection alignment + if (selectionNodes.length === 1 && $isElementNode(selectionNodes[0]) && selectionNodes[0].isInline() && nodeHasAlignment(selectionNodes[0])) { + selectionNodes[0].setAlignment(alignment); + $selectSingleNode(selectionNodes[0]); + $toggleSelection(editor); return; } @@ -33,6 +32,7 @@ function setAlignmentForSection(editor: LexicalEditor, alignment: CommonBlockAli node.setAlignment(alignment) } } + $toggleSelection(editor); } export const alignLeft: EditorButtonDefinition = { diff --git a/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts new file mode 100644 index 000000000..cceb58b6b --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts @@ -0,0 +1,167 @@ +import {BaseSelection,} from "lexical"; +import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; +import {el} from "../../../utils/dom"; +import {$isImageNode, ImageNode} from "../../../nodes/image"; +import {EditorUiContext} from "../core"; + +class ImageResizer { + protected context: EditorUiContext; + protected dom: HTMLElement|null = null; + protected scrollContainer: HTMLElement; + + protected mouseTracker: MouseDragTracker|null = null; + protected activeSelection: string = ''; + + constructor(context: EditorUiContext) { + this.context = context; + this.scrollContainer = context.scrollDOM; + + this.onSelectionChange = this.onSelectionChange.bind(this); + context.manager.onSelectionChange(this.onSelectionChange); + } + + onSelectionChange(selection: BaseSelection|null) { + const nodes = selection?.getNodes() || []; + if (this.activeSelection) { + this.hide(); + } + + if (nodes.length === 1 && $isImageNode(nodes[0])) { + const imageNode = nodes[0]; + const nodeKey = imageNode.getKey(); + const imageDOM = this.context.editor.getElementByKey(nodeKey); + + if (imageDOM) { + this.showForImage(imageNode, imageDOM); + } + } + } + + teardown() { + this.context.manager.offSelectionChange(this.onSelectionChange); + this.hide(); + } + + protected showForImage(node: ImageNode, dom: HTMLElement) { + this.dom = this.buildDOM(); + + const ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-image-resizer-ghost'}); + this.dom.append(ghost); + + this.context.scrollDOM.append(this.dom); + this.updateDOMPosition(dom); + + this.mouseTracker = this.setupTracker(this.dom, node, dom); + this.activeSelection = node.getKey(); + } + + protected updateDOMPosition(imageDOM: HTMLElement) { + if (!this.dom) { + return; + } + + const imageBounds = imageDOM.getBoundingClientRect(); + this.dom.style.left = imageDOM.offsetLeft + 'px'; + this.dom.style.top = imageDOM.offsetTop + 'px'; + this.dom.style.width = imageBounds.width + 'px'; + this.dom.style.height = imageBounds.height + 'px'; + } + + protected updateDOMSize(width: number, height: number): void { + if (!this.dom) { + return; + } + + this.dom.style.width = width + 'px'; + this.dom.style.height = height + 'px'; + } + + protected hide() { + this.mouseTracker?.teardown(); + this.dom?.remove(); + this.activeSelection = ''; + } + + protected buildDOM() { + const handleClasses = ['nw', 'ne', 'se', 'sw']; + const handleElems = handleClasses.map(c => { + return el('div', {class: `editor-image-resizer-handle ${c}`}); + }); + + return el('div', { + class: 'editor-image-resizer', + }, handleElems); + } + + setupTracker(container: HTMLElement, node: ImageNode, imageDOM: HTMLElement): MouseDragTracker { + let startingWidth: number = 0; + let startingHeight: number = 0; + let startingRatio: number = 0; + let hasHeight = false; + let _this = this; + let flipXChange: boolean = false; + let flipYChange: boolean = false; + + const calculateSize = (distance: MouseDragTrackerDistance): {width: number, height: number} => { + let xChange = distance.x; + if (flipXChange) { + xChange = 0 - xChange; + } + let yChange = distance.y; + if (flipYChange) { + yChange = 0 - yChange; + } + + const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2)); + const increase = xChange + yChange > 0; + const directedChange = increase ? balancedChange : 0-balancedChange; + const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); + const newHeight = newWidth * startingRatio; + + return {width: newWidth, height: newHeight}; + }; + + return new MouseDragTracker(container, '.editor-image-resizer-handle', { + down(event: MouseEvent, handle: HTMLElement) { + _this.dom?.classList.add('active'); + _this.context.editor.getEditorState().read(() => { + const imageRect = imageDOM.getBoundingClientRect(); + startingWidth = node.getWidth() || imageRect.width; + startingHeight = node.getHeight() || imageRect.height; + if (node.getHeight()) { + hasHeight = true; + } + startingRatio = startingWidth / startingHeight; + }); + + flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw'); + flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne'); + }, + move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) { + const size = calculateSize(distance); + _this.updateDOMSize(size.width, size.height); + }, + up(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) { + const size = calculateSize(distance); + _this.context.editor.update(() => { + node.setWidth(size.width); + node.setHeight(hasHeight ? size.height : 0); + _this.context.manager.triggerLayoutUpdate(); + requestAnimationFrame(() => { + _this.updateDOMPosition(imageDOM); + }) + }); + _this.dom?.classList.remove('active'); + } + }); + } +} + + +export function registerImageResizer(context: EditorUiContext): (() => void) { + const resizer = new ImageResizer(context); + + return () => { + resizer.teardown(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index f10e85b47..8fda66cb2 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -144,6 +144,14 @@ export class EditorUIManager { this.selectionChangeHandlers.delete(handler); } + triggerLayoutUpdate(): void { + window.requestAnimationFrame(() => { + for (const toolbar of this.activeContextToolbars) { + toolbar.updatePosition(); + } + }); + } + protected updateContextToolbars(update: EditorUiStateUpdate): void { for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) { const toolbar = this.activeContextToolbars[i]; @@ -220,13 +228,8 @@ export class EditorUIManager { } protected setupEventListeners(context: EditorUiContext) { - const updateToolbars = (event: Event) => { - for (const toolbar of this.activeContextToolbars) { - toolbar.updatePosition(); - } - }; - - window.addEventListener('scroll', updateToolbars, {capture: true, passive: true}); - window.addEventListener('resize', updateToolbars, {passive: true}); + const layoutUpdate = this.triggerLayoutUpdate.bind(this); + window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true}); + window.addEventListener('resize', layoutUpdate, {passive: true}); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 3b6d195b7..71a2623d6 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -6,7 +6,6 @@ import { getMainEditorFullToolbar, getTableToolbarContent } from "./toolbars"; import {EditorUIManager} from "./framework/manager"; -import {ImageDecorator} from "./decorators/image"; import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; import {DiagramDecorator} from "./decorators/diagram"; @@ -64,7 +63,6 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro }); // Register image decorator listener - manager.registerDecoratorType('image', ImageDecorator); manager.registerDecoratorType('code', CodeBlockDecorator); manager.registerDecoratorType('diagram', DiagramDecorator); diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 791eb7499..4f565fa10 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -1,6 +1,6 @@ import { $createNodeSelection, - $createParagraphNode, + $createParagraphNode, $createRangeSelection, $getRoot, $getSelection, $isDecoratorNode, $isElementNode, @@ -106,6 +106,18 @@ export function $selectSingleNode(node: LexicalNode) { $setSelection(nodeSelection); } +export function $toggleSelection(editor: LexicalEditor) { + const lastSelection = getLastSelection(editor); + + if (lastSelection) { + window.requestAnimationFrame(() => { + editor.update(() => { + $setSelection(lastSelection.clone()); + }) + }); + } +} + export function $selectionContainsNode(selection: BaseSelection | null, node: LexicalNode): boolean { if (!selection) { return false; @@ -122,7 +134,11 @@ export function $selectionContainsNode(selection: BaseSelection | null, node: Le } export function $selectionContainsAlignment(selection: BaseSelection | null, alignment: CommonBlockAlignment): boolean { - const nodes = $getBlockElementNodesInSelection(selection); + + const nodes = [ + ...(selection?.getNodes() || []), + ...$getBlockElementNodesInSelection(selection) + ]; for (const node of nodes) { if (nodeHasAlignment(node) && node.getAlignment() === alignment) { return true; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 78e518bd5..80633df94 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -31,6 +31,7 @@ body.editor-is-fullscreen { } } .editor-content-wrap { + position: relative; overflow-y: scroll; } @@ -287,23 +288,20 @@ body.editor-is-fullscreen { position: relative; display: inline-flex; } -.editor-image-decorator { +.editor-image-resizer { position: absolute; left: 0; right: 0; - width: 100%; - height: 100%; display: inline-block; - &.selected { - border: 1px dashed var(--editor-color-primary); - } + outline: 2px dashed var(--editor-color-primary); } -.editor-image-decorator-handle { +.editor-image-resizer-handle { position: absolute; display: block; width: 10px; height: 10px; border: 2px solid var(--editor-color-primary); + z-index: 3; background-color: #FFF; user-select: none; &.nw { @@ -327,6 +325,20 @@ body.editor-is-fullscreen { cursor: sw-resize; } } +.editor-image-resizer-ghost { + opacity: 0.5; + display: none; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 2; + pointer-events: none; +} +.editor-image-resizer.active .editor-image-resizer-ghost { + display: block; +} .editor-table-marker { position: fixed;