From bed2c29a33f6e109ce1dd2ef76fc9fbd7a217080 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 8 Sep 2024 13:37:13 +0100 Subject: [PATCH] Lexical: Added media resize support via drag handles --- resources/js/wysiwyg/index.ts | 4 +- resources/js/wysiwyg/nodes/_common.ts | 7 ++ resources/js/wysiwyg/nodes/callout.ts | 1 + resources/js/wysiwyg/nodes/media.ts | 55 +++++++++++++- resources/js/wysiwyg/todo.md | 6 +- .../ui/framework/helpers/image-resizer.ts | 71 +++++++++++-------- resources/js/wysiwyg/utils/dom.ts | 16 +++++ resources/sass/_editor.scss | 16 +++-- 8 files changed, 133 insertions(+), 43 deletions(-) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index c312919db..64b59492b 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -13,7 +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"; +import {registerNodeResizer} from "./ui/framework/helpers/image-resizer"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -56,7 +56,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerTableSelectionHandler(editor), registerTaskListHandler(editor, editArea), registerDropPasteHandling(context), - registerImageResizer(context), + registerNodeResizer(context), ); listenToCommonEvents(editor); diff --git a/resources/js/wysiwyg/nodes/_common.ts b/resources/js/wysiwyg/nodes/_common.ts index cc45dc910..ff957f953 100644 --- a/resources/js/wysiwyg/nodes/_common.ts +++ b/resources/js/wysiwyg/nodes/_common.ts @@ -63,4 +63,11 @@ export function updateElementWithCommonBlockProps(element: HTMLElement, node: Co if (node.__alignment) { element.classList.add('align-' + node.__alignment); } +} + +export interface NodeHasSize { + setHeight(height: number): void; + setWidth(width: number): void; + getHeight(): number; + getWidth(): number; } \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/callout.ts b/resources/js/wysiwyg/nodes/callout.ts index 8018190c8..ededc0f29 100644 --- a/resources/js/wysiwyg/nodes/callout.ts +++ b/resources/js/wysiwyg/nodes/callout.ts @@ -34,6 +34,7 @@ export class CalloutNode extends ElementNode { static clone(node: CalloutNode) { const newNode = new CalloutNode(node.__category, node.__key); newNode.__id = node.__id; + newNode.__alignment = node.__alignment; return newNode; } diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts index 4159cd457..5b3c1b9c2 100644 --- a/resources/js/wysiwyg/nodes/media.ts +++ b/resources/js/wysiwyg/nodes/media.ts @@ -1,6 +1,6 @@ import { DOMConversion, - DOMConversionMap, DOMConversionOutput, + DOMConversionMap, DOMConversionOutput, DOMExportOutput, ElementNode, LexicalEditor, LexicalNode, @@ -8,7 +8,7 @@ import { } from 'lexical'; import type {EditorConfig} from "lexical/LexicalEditor"; -import {el} from "../utils/dom"; +import {el, sizeToPixels} from "../utils/dom"; import { CommonBlockAlignment, SerializedCommonBlockNode, @@ -16,6 +16,7 @@ import { updateElementWithCommonBlockProps } from "./_common"; import {elem} from "../../services/dom"; +import {$selectSingleNode} from "../utils/selection"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeSource = { @@ -89,6 +90,8 @@ export class MediaNode extends ElementNode { const newNode = new MediaNode(node.__tag, node.__key); newNode.__attributes = Object.assign({}, node.__attributes); newNode.__sources = node.__sources.map(s => Object.assign({}, s)); + newNode.__id = node.__id; + newNode.__alignment = node.__alignment; return newNode; } @@ -166,7 +169,35 @@ export class MediaNode extends ElementNode { return self.__alignment; } - createDOM(_config: EditorConfig, _editor: LexicalEditor) { + setHeight(height: number): void { + if (!height) { + return; + } + + const attrs = Object.assign({}, this.getAttributes(), {height}); + this.setAttributes(attrs); + } + + getHeight(): number { + const self = this.getLatest(); + return sizeToPixels(self.__attributes.height || '0'); + } + + setWidth(width: number): void { + const attrs = Object.assign({}, this.getAttributes(), {width}); + this.setAttributes(attrs); + } + + getWidth(): number { + const self = this.getLatest(); + return sizeToPixels(self.__attributes.width || '0'); + } + + isInline(): boolean { + return true; + } + + createInnerDOM() { const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : []; const sourceEls = sources.map(source => el('source', source)); const element = el(this.__tag, this.__attributes, sourceEls); @@ -174,6 +205,19 @@ export class MediaNode extends ElementNode { return element; } + createDOM(_config: EditorConfig, _editor: LexicalEditor) { + const media = this.createInnerDOM(); + const wrap = el('span', { + class: media.className + ' editor-media-wrap', + }, [media]); + + wrap.addEventListener('click', e => { + _editor.update(() => $selectSingleNode(this)); + }); + + return wrap; + } + updateDOM(prevNode: unknown, dom: HTMLElement) { return true; } @@ -202,6 +246,11 @@ export class MediaNode extends ElementNode { }; } + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createInnerDOM(); + return { element }; + } + exportJSON(): SerializedMediaNode { return { ...super.exportJSON(), diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 064d65e4c..92042295c 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,7 +6,6 @@ ## Main Todo -- Media resize support (like images) - Mac: Shortcut support via command. ## Secondary Todo @@ -17,9 +16,6 @@ ## Bugs -- Can't select iframe embeds by themselves. (click enters iframe) - 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). -- Table row copy/paste does not handle merged cells - - TinyMCE fills gaps with the cells that would be visually in the row \ No newline at end of file +- Template drag/drop not handled when outside core editor area (ignored in margin area). \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts index cceb58b6b..c8105eafc 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/image-resizer.ts @@ -1,10 +1,16 @@ -import {BaseSelection,} from "lexical"; +import {BaseSelection, LexicalNode,} from "lexical"; import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; import {el} from "../../../utils/dom"; -import {$isImageNode, ImageNode} from "../../../nodes/image"; +import {$isImageNode} from "../../../nodes/image"; import {EditorUiContext} from "../core"; +import {NodeHasSize} from "../../../nodes/_common"; +import {$isMediaNode} from "../../../nodes/media"; -class ImageResizer { +function isNodeWithSize(node: LexicalNode): node is NodeHasSize&LexicalNode { + return $isImageNode(node) || $isMediaNode(node); +} + +class NodeResizer { protected context: EditorUiContext; protected dom: HTMLElement|null = null; protected scrollContainer: HTMLElement; @@ -26,13 +32,17 @@ class ImageResizer { 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 (nodes.length === 1 && isNodeWithSize(nodes[0])) { + const node = nodes[0]; + const nodeKey = node.getKey(); + let nodeDOM = this.context.editor.getElementByKey(nodeKey); - if (imageDOM) { - this.showForImage(imageNode, imageDOM); + if (nodeDOM && nodeDOM.nodeName === 'SPAN') { + nodeDOM = nodeDOM.firstElementChild as HTMLElement; + } + + if (nodeDOM) { + this.showForNode(node, nodeDOM); } } } @@ -42,10 +52,13 @@ class ImageResizer { this.hide(); } - protected showForImage(node: ImageNode, dom: HTMLElement) { + protected showForNode(node: NodeHasSize&LexicalNode, dom: HTMLElement) { this.dom = this.buildDOM(); - const ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-image-resizer-ghost'}); + let ghost = el('span', {class: 'editor-node-resizer-ghost'}); + if ($isImageNode(node)) { + ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-node-resizer-ghost'}); + } this.dom.append(ghost); this.context.scrollDOM.append(this.dom); @@ -55,16 +68,16 @@ class ImageResizer { this.activeSelection = node.getKey(); } - protected updateDOMPosition(imageDOM: HTMLElement) { + protected updateDOMPosition(nodeDOM: 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'; + const nodeDOMBounds = nodeDOM.getBoundingClientRect(); + this.dom.style.left = nodeDOM.offsetLeft + 'px'; + this.dom.style.top = nodeDOM.offsetTop + 'px'; + this.dom.style.width = nodeDOMBounds.width + 'px'; + this.dom.style.height = nodeDOMBounds.height + 'px'; } protected updateDOMSize(width: number, height: number): void { @@ -85,15 +98,15 @@ class ImageResizer { 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-node-resizer-handle ${c}`}); }); return el('div', { - class: 'editor-image-resizer', + class: 'editor-node-resizer', }, handleElems); } - setupTracker(container: HTMLElement, node: ImageNode, imageDOM: HTMLElement): MouseDragTracker { + setupTracker(container: HTMLElement, node: NodeHasSize, nodeDOM: HTMLElement): MouseDragTracker { let startingWidth: number = 0; let startingHeight: number = 0; let startingRatio: number = 0; @@ -116,22 +129,22 @@ class ImageResizer { const increase = xChange + yChange > 0; const directedChange = increase ? balancedChange : 0-balancedChange; const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); - const newHeight = newWidth * startingRatio; + const newHeight = Math.round(newWidth * startingRatio); return {width: newWidth, height: newHeight}; }; - return new MouseDragTracker(container, '.editor-image-resizer-handle', { + return new MouseDragTracker(container, '.editor-node-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; + const domRect = nodeDOM.getBoundingClientRect(); + startingWidth = node.getWidth() || domRect.width; + startingHeight = node.getHeight() || domRect.height; if (node.getHeight()) { hasHeight = true; } - startingRatio = startingWidth / startingHeight; + startingRatio = startingHeight / startingWidth; }); flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw'); @@ -148,7 +161,7 @@ class ImageResizer { node.setHeight(hasHeight ? size.height : 0); _this.context.manager.triggerLayoutUpdate(); requestAnimationFrame(() => { - _this.updateDOMPosition(imageDOM); + _this.updateDOMPosition(nodeDOM); }) }); _this.dom?.classList.remove('active'); @@ -158,8 +171,8 @@ class ImageResizer { } -export function registerImageResizer(context: EditorUiContext): (() => void) { - const resizer = new ImageResizer(context); +export function registerNodeResizer(context: EditorUiContext): (() => void) { + const resizer = new NodeResizer(context); return () => { resizer.teardown(); diff --git a/resources/js/wysiwyg/utils/dom.ts b/resources/js/wysiwyg/utils/dom.ts index a307bdd75..d5c63a816 100644 --- a/resources/js/wysiwyg/utils/dom.ts +++ b/resources/js/wysiwyg/utils/dom.ts @@ -31,6 +31,22 @@ export function formatSizeValue(size: number | string, defaultSuffix: string = ' return size; } +export function sizeToPixels(size: string): number { + if (/^-?\d+$/.test(size)) { + return Number(size); + } + + if (/^-?\d+\.\d+$/.test(size)) { + return Math.round(Number(size)); + } + + if (/^-?\d+px\s*$/.test(size)) { + return Number(size.trim().replace('px', '')); + } + + return 0; +} + export type StyleMap = Map; /** diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 80633df94..31ce564be 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -288,14 +288,14 @@ body.editor-is-fullscreen { position: relative; display: inline-flex; } -.editor-image-resizer { +.editor-node-resizer { position: absolute; left: 0; right: 0; display: inline-block; outline: 2px dashed var(--editor-color-primary); } -.editor-image-resizer-handle { +.editor-node-resizer-handle { position: absolute; display: block; width: 10px; @@ -325,7 +325,7 @@ body.editor-is-fullscreen { cursor: sw-resize; } } -.editor-image-resizer-ghost { +.editor-node-resizer-ghost { opacity: 0.5; display: none; position: absolute; @@ -335,8 +335,9 @@ body.editor-is-fullscreen { height: 100%; z-index: 2; pointer-events: none; + background-color: var(--editor-color-primary); } -.editor-image-resizer.active .editor-image-resizer-ghost { +.editor-node-resizer.active .editor-node-resizer-ghost { display: block; } @@ -372,6 +373,13 @@ body.editor-is-fullscreen { outline: 2px dashed var(--editor-color-primary); } +.editor-media-wrap { + cursor: not-allowed; + iframe { + pointer-events: none; + } +} + /** * Fake task list checkboxes */