Lexical: Added media resize support via drag handles

This commit is contained in:
Dan Brown 2024-09-08 13:37:13 +01:00
parent e5b6d28bca
commit bed2c29a33
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
8 changed files with 133 additions and 43 deletions

View File

@ -13,7 +13,7 @@ import {registerTaskListHandler} from "./ui/framework/helpers/task-list-handler"
import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler"; import {registerTableSelectionHandler} from "./ui/framework/helpers/table-selection-handler";
import {el} from "./utils/dom"; import {el} from "./utils/dom";
import {registerShortcuts} from "./services/shortcuts"; 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<string, any> = {}): SimpleWysiwygEditorInterface { export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
const config: CreateEditorArgs = { const config: CreateEditorArgs = {
@ -56,7 +56,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
registerTableSelectionHandler(editor), registerTableSelectionHandler(editor),
registerTaskListHandler(editor, editArea), registerTaskListHandler(editor, editArea),
registerDropPasteHandling(context), registerDropPasteHandling(context),
registerImageResizer(context), registerNodeResizer(context),
); );
listenToCommonEvents(editor); listenToCommonEvents(editor);

View File

@ -64,3 +64,10 @@ export function updateElementWithCommonBlockProps(element: HTMLElement, node: Co
element.classList.add('align-' + node.__alignment); element.classList.add('align-' + node.__alignment);
} }
} }
export interface NodeHasSize {
setHeight(height: number): void;
setWidth(width: number): void;
getHeight(): number;
getWidth(): number;
}

View File

@ -34,6 +34,7 @@ export class CalloutNode extends ElementNode {
static clone(node: CalloutNode) { static clone(node: CalloutNode) {
const newNode = new CalloutNode(node.__category, node.__key); const newNode = new CalloutNode(node.__category, node.__key);
newNode.__id = node.__id; newNode.__id = node.__id;
newNode.__alignment = node.__alignment;
return newNode; return newNode;
} }

View File

@ -1,6 +1,6 @@
import { import {
DOMConversion, DOMConversion,
DOMConversionMap, DOMConversionOutput, DOMConversionMap, DOMConversionOutput, DOMExportOutput,
ElementNode, ElementNode,
LexicalEditor, LexicalEditor,
LexicalNode, LexicalNode,
@ -8,7 +8,7 @@ import {
} from 'lexical'; } from 'lexical';
import type {EditorConfig} from "lexical/LexicalEditor"; import type {EditorConfig} from "lexical/LexicalEditor";
import {el} from "../utils/dom"; import {el, sizeToPixels} from "../utils/dom";
import { import {
CommonBlockAlignment, CommonBlockAlignment,
SerializedCommonBlockNode, SerializedCommonBlockNode,
@ -16,6 +16,7 @@ import {
updateElementWithCommonBlockProps updateElementWithCommonBlockProps
} from "./_common"; } from "./_common";
import {elem} from "../../services/dom"; import {elem} from "../../services/dom";
import {$selectSingleNode} from "../utils/selection";
export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
export type MediaNodeSource = { export type MediaNodeSource = {
@ -89,6 +90,8 @@ export class MediaNode extends ElementNode {
const newNode = new MediaNode(node.__tag, node.__key); const newNode = new MediaNode(node.__tag, node.__key);
newNode.__attributes = Object.assign({}, node.__attributes); newNode.__attributes = Object.assign({}, node.__attributes);
newNode.__sources = node.__sources.map(s => Object.assign({}, s)); newNode.__sources = node.__sources.map(s => Object.assign({}, s));
newNode.__id = node.__id;
newNode.__alignment = node.__alignment;
return newNode; return newNode;
} }
@ -166,7 +169,35 @@ export class MediaNode extends ElementNode {
return self.__alignment; 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 sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : [];
const sourceEls = sources.map(source => el('source', source)); const sourceEls = sources.map(source => el('source', source));
const element = el(this.__tag, this.__attributes, sourceEls); const element = el(this.__tag, this.__attributes, sourceEls);
@ -174,6 +205,19 @@ export class MediaNode extends ElementNode {
return element; 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) { updateDOM(prevNode: unknown, dom: HTMLElement) {
return true; return true;
} }
@ -202,6 +246,11 @@ export class MediaNode extends ElementNode {
}; };
} }
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = this.createInnerDOM();
return { element };
}
exportJSON(): SerializedMediaNode { exportJSON(): SerializedMediaNode {
return { return {
...super.exportJSON(), ...super.exportJSON(),

View File

@ -6,7 +6,6 @@
## Main Todo ## Main Todo
- Media resize support (like images)
- Mac: Shortcut support via command. - Mac: Shortcut support via command.
## Secondary Todo ## Secondary Todo
@ -17,9 +16,6 @@
## Bugs ## Bugs
- Can't select iframe embeds by themselves. (click enters iframe)
- Removing link around image via button deletes image, not just link - 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. - `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). - 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

View File

@ -1,10 +1,16 @@
import {BaseSelection,} from "lexical"; import {BaseSelection, LexicalNode,} from "lexical";
import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker";
import {el} from "../../../utils/dom"; import {el} from "../../../utils/dom";
import {$isImageNode, ImageNode} from "../../../nodes/image"; import {$isImageNode} from "../../../nodes/image";
import {EditorUiContext} from "../core"; 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 context: EditorUiContext;
protected dom: HTMLElement|null = null; protected dom: HTMLElement|null = null;
protected scrollContainer: HTMLElement; protected scrollContainer: HTMLElement;
@ -26,13 +32,17 @@ class ImageResizer {
this.hide(); this.hide();
} }
if (nodes.length === 1 && $isImageNode(nodes[0])) { if (nodes.length === 1 && isNodeWithSize(nodes[0])) {
const imageNode = nodes[0]; const node = nodes[0];
const nodeKey = imageNode.getKey(); const nodeKey = node.getKey();
const imageDOM = this.context.editor.getElementByKey(nodeKey); let nodeDOM = this.context.editor.getElementByKey(nodeKey);
if (imageDOM) { if (nodeDOM && nodeDOM.nodeName === 'SPAN') {
this.showForImage(imageNode, imageDOM); nodeDOM = nodeDOM.firstElementChild as HTMLElement;
}
if (nodeDOM) {
this.showForNode(node, nodeDOM);
} }
} }
} }
@ -42,10 +52,13 @@ class ImageResizer {
this.hide(); this.hide();
} }
protected showForImage(node: ImageNode, dom: HTMLElement) { protected showForNode(node: NodeHasSize&LexicalNode, dom: HTMLElement) {
this.dom = this.buildDOM(); 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.dom.append(ghost);
this.context.scrollDOM.append(this.dom); this.context.scrollDOM.append(this.dom);
@ -55,16 +68,16 @@ class ImageResizer {
this.activeSelection = node.getKey(); this.activeSelection = node.getKey();
} }
protected updateDOMPosition(imageDOM: HTMLElement) { protected updateDOMPosition(nodeDOM: HTMLElement) {
if (!this.dom) { if (!this.dom) {
return; return;
} }
const imageBounds = imageDOM.getBoundingClientRect(); const nodeDOMBounds = nodeDOM.getBoundingClientRect();
this.dom.style.left = imageDOM.offsetLeft + 'px'; this.dom.style.left = nodeDOM.offsetLeft + 'px';
this.dom.style.top = imageDOM.offsetTop + 'px'; this.dom.style.top = nodeDOM.offsetTop + 'px';
this.dom.style.width = imageBounds.width + 'px'; this.dom.style.width = nodeDOMBounds.width + 'px';
this.dom.style.height = imageBounds.height + 'px'; this.dom.style.height = nodeDOMBounds.height + 'px';
} }
protected updateDOMSize(width: number, height: number): void { protected updateDOMSize(width: number, height: number): void {
@ -85,15 +98,15 @@ class ImageResizer {
protected buildDOM() { protected buildDOM() {
const handleClasses = ['nw', 'ne', 'se', 'sw']; const handleClasses = ['nw', 'ne', 'se', 'sw'];
const handleElems = handleClasses.map(c => { 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', { return el('div', {
class: 'editor-image-resizer', class: 'editor-node-resizer',
}, handleElems); }, handleElems);
} }
setupTracker(container: HTMLElement, node: ImageNode, imageDOM: HTMLElement): MouseDragTracker { setupTracker(container: HTMLElement, node: NodeHasSize, nodeDOM: HTMLElement): MouseDragTracker {
let startingWidth: number = 0; let startingWidth: number = 0;
let startingHeight: number = 0; let startingHeight: number = 0;
let startingRatio: number = 0; let startingRatio: number = 0;
@ -116,22 +129,22 @@ class ImageResizer {
const increase = xChange + yChange > 0; const increase = xChange + yChange > 0;
const directedChange = increase ? balancedChange : 0-balancedChange; const directedChange = increase ? balancedChange : 0-balancedChange;
const newWidth = Math.max(5, Math.round(startingWidth + directedChange)); 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 {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) { down(event: MouseEvent, handle: HTMLElement) {
_this.dom?.classList.add('active'); _this.dom?.classList.add('active');
_this.context.editor.getEditorState().read(() => { _this.context.editor.getEditorState().read(() => {
const imageRect = imageDOM.getBoundingClientRect(); const domRect = nodeDOM.getBoundingClientRect();
startingWidth = node.getWidth() || imageRect.width; startingWidth = node.getWidth() || domRect.width;
startingHeight = node.getHeight() || imageRect.height; startingHeight = node.getHeight() || domRect.height;
if (node.getHeight()) { if (node.getHeight()) {
hasHeight = true; hasHeight = true;
} }
startingRatio = startingWidth / startingHeight; startingRatio = startingHeight / startingWidth;
}); });
flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw'); flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw');
@ -148,7 +161,7 @@ class ImageResizer {
node.setHeight(hasHeight ? size.height : 0); node.setHeight(hasHeight ? size.height : 0);
_this.context.manager.triggerLayoutUpdate(); _this.context.manager.triggerLayoutUpdate();
requestAnimationFrame(() => { requestAnimationFrame(() => {
_this.updateDOMPosition(imageDOM); _this.updateDOMPosition(nodeDOM);
}) })
}); });
_this.dom?.classList.remove('active'); _this.dom?.classList.remove('active');
@ -158,8 +171,8 @@ class ImageResizer {
} }
export function registerImageResizer(context: EditorUiContext): (() => void) { export function registerNodeResizer(context: EditorUiContext): (() => void) {
const resizer = new ImageResizer(context); const resizer = new NodeResizer(context);
return () => { return () => {
resizer.teardown(); resizer.teardown();

View File

@ -31,6 +31,22 @@ export function formatSizeValue(size: number | string, defaultSuffix: string = '
return size; 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<string, string>; export type StyleMap = Map<string, string>;
/** /**

View File

@ -288,14 +288,14 @@ body.editor-is-fullscreen {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
} }
.editor-image-resizer { .editor-node-resizer {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
display: inline-block; display: inline-block;
outline: 2px dashed var(--editor-color-primary); outline: 2px dashed var(--editor-color-primary);
} }
.editor-image-resizer-handle { .editor-node-resizer-handle {
position: absolute; position: absolute;
display: block; display: block;
width: 10px; width: 10px;
@ -325,7 +325,7 @@ body.editor-is-fullscreen {
cursor: sw-resize; cursor: sw-resize;
} }
} }
.editor-image-resizer-ghost { .editor-node-resizer-ghost {
opacity: 0.5; opacity: 0.5;
display: none; display: none;
position: absolute; position: absolute;
@ -335,8 +335,9 @@ body.editor-is-fullscreen {
height: 100%; height: 100%;
z-index: 2; z-index: 2;
pointer-events: none; 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; display: block;
} }
@ -372,6 +373,13 @@ body.editor-is-fullscreen {
outline: 2px dashed var(--editor-color-primary); outline: 2px dashed var(--editor-color-primary);
} }
.editor-media-wrap {
cursor: not-allowed;
iframe {
pointer-events: none;
}
}
/** /**
* Fake task list checkboxes * Fake task list checkboxes
*/ */