1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-07-30 04:23:11 +03:00

Lexical: Fixed media resize handling

- Updating height/width setting to clear any inline CSS width/height
  rules which would override and prevent resizes showing. This was
  common when switching media from old editor.
  Added test to cover.
- Updated resizer to track node so that it is retained & displayed
  across node DOM changes, which was previously causing the
  resizer/focus to disappear.
This commit is contained in:
Dan Brown
2025-06-15 13:55:42 +01:00
parent 77a88618c2
commit 8d4b8ff4f3
6 changed files with 107 additions and 29 deletions

View File

@ -38,6 +38,7 @@ import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
import {EditorUiContext} from "../../../../ui/framework/core"; import {EditorUiContext} from "../../../../ui/framework/core";
import {EditorUIManager} from "../../../../ui/framework/manager"; import {EditorUIManager} from "../../../../ui/framework/manager";
import {ImageNode} from "@lexical/rich-text/LexicalImageNode"; import {ImageNode} from "@lexical/rich-text/LexicalImageNode";
import {MediaNode} from "@lexical/rich-text/LexicalMediaNode";
type TestEnv = { type TestEnv = {
readonly container: HTMLDivElement; readonly container: HTMLDivElement;
@ -487,6 +488,7 @@ export function createTestContext(): EditorUiContext {
theme: {}, theme: {},
nodes: [ nodes: [
ImageNode, ImageNode,
MediaNode,
] ]
}); });

View File

@ -8,7 +8,7 @@ import {
} from 'lexical'; } from 'lexical';
import type {EditorConfig} from "lexical/LexicalEditor"; import type {EditorConfig} from "lexical/LexicalEditor";
import {el, setOrRemoveAttribute, sizeToPixels} from "../../utils/dom"; import {el, setOrRemoveAttribute, sizeToPixels, styleMapToStyleString, styleStringToStyleMap} from "../../utils/dom";
import { import {
CommonBlockAlignment, deserializeCommonBlockNode, CommonBlockAlignment, deserializeCommonBlockNode,
setCommonBlockPropsFromElement, setCommonBlockPropsFromElement,
@ -46,6 +46,19 @@ function filterAttributes(attributes: Record<string, string>): Record<string, st
return filtered; return filtered;
} }
function removeStyleFromAttributes(attributes: Record<string, string>, styleName: string): Record<string, string> {
const attrCopy = Object.assign({}, attributes);
if (!attributes.style) {
return attrCopy;
}
const map = styleStringToStyleMap(attributes.style);
map.delete(styleName);
attrCopy.style = styleMapToStyleString(map);
return attrCopy;
}
function domElementToNode(tag: MediaNodeTag, element: HTMLElement): MediaNode { function domElementToNode(tag: MediaNodeTag, element: HTMLElement): MediaNode {
const node = $createMediaNode(tag); const node = $createMediaNode(tag);
@ -118,7 +131,7 @@ export class MediaNode extends ElementNode {
getAttributes(): Record<string, string> { getAttributes(): Record<string, string> {
const self = this.getLatest(); const self = this.getLatest();
return self.__attributes; return Object.assign({}, self.__attributes);
} }
setSources(sources: MediaNodeSource[]) { setSources(sources: MediaNodeSource[]) {
@ -132,7 +145,7 @@ export class MediaNode extends ElementNode {
} }
setSrc(src: string): void { setSrc(src: string): void {
const attrs = Object.assign({}, this.getAttributes()); const attrs = this.getAttributes();
if (this.__tag ==='object') { if (this.__tag ==='object') {
attrs.data = src; attrs.data = src;
} else { } else {
@ -142,11 +155,13 @@ export class MediaNode extends ElementNode {
} }
setWidthAndHeight(width: string, height: string): void { setWidthAndHeight(width: string, height: string): void {
const attrs = Object.assign( let attrs: Record<string, string> = Object.assign(
{},
this.getAttributes(), this.getAttributes(),
{width, height}, {width, height},
); );
attrs = removeStyleFromAttributes(attrs, 'width');
attrs = removeStyleFromAttributes(attrs, 'height');
this.setAttributes(attrs); this.setAttributes(attrs);
} }
@ -185,8 +200,8 @@ export class MediaNode extends ElementNode {
return; return;
} }
const attrs = Object.assign({}, this.getAttributes(), {height}); const attrs = Object.assign(this.getAttributes(), {height});
this.setAttributes(attrs); this.setAttributes(removeStyleFromAttributes(attrs, 'height'));
} }
getHeight(): number { getHeight(): number {
@ -195,8 +210,9 @@ export class MediaNode extends ElementNode {
} }
setWidth(width: number): void { setWidth(width: number): void {
const attrs = Object.assign({}, this.getAttributes(), {width}); const existingAttrs = this.getAttributes();
this.setAttributes(attrs); const attrs: Record<string, string> = Object.assign(existingAttrs, {width});
this.setAttributes(removeStyleFromAttributes(attrs, 'width'));
} }
getWidth(): number { getWidth(): number {

View File

@ -0,0 +1,31 @@
import {createTestContext} from "lexical/__tests__/utils";
import {$createMediaNode} from "@lexical/rich-text/LexicalMediaNode";
describe('LexicalMediaNode', () => {
test('setWidth/setHeight/setWidthAndHeight functions remove relevant styles', () => {
const {editor} = createTestContext();
editor.updateAndCommit(() => {
const mediaMode = $createMediaNode('video');
const defaultStyles = {style: 'width:20px;height:40px;color:red'};
mediaMode.setAttributes(defaultStyles);
mediaMode.setWidth(60);
expect(mediaMode.getWidth()).toBe(60);
expect(mediaMode.getAttributes().style).toBe('height:40px;color:red');
mediaMode.setAttributes(defaultStyles);
mediaMode.setHeight(77);
expect(mediaMode.getHeight()).toBe(77);
expect(mediaMode.getAttributes().style).toBe('width:20px;color:red');
mediaMode.setAttributes(defaultStyles);
mediaMode.setWidthAndHeight('6', '7');
expect(mediaMode.getWidth()).toBe(6);
expect(mediaMode.getHeight()).toBe(7);
expect(mediaMode.getAttributes().style).toBe('color:red');
});
});
});

View File

@ -13,7 +13,7 @@ function isNodeWithSize(node: LexicalNode): node is NodeHasSize&LexicalNode {
class NodeResizer { class NodeResizer {
protected context: EditorUiContext; protected context: EditorUiContext;
protected resizerDOM: HTMLElement|null = null; protected resizerDOM: HTMLElement|null = null;
protected targetDOM: HTMLElement|null = null; protected targetNode: LexicalNode|null = null;
protected scrollContainer: HTMLElement; protected scrollContainer: HTMLElement;
protected mouseTracker: MouseDragTracker|null = null; protected mouseTracker: MouseDragTracker|null = null;
@ -38,12 +38,7 @@ class NodeResizer {
if (nodes.length === 1 && isNodeWithSize(nodes[0])) { if (nodes.length === 1 && isNodeWithSize(nodes[0])) {
const node = nodes[0]; const node = nodes[0];
const nodeKey = node.getKey(); let nodeDOM = this.getTargetDOM(node)
let nodeDOM = this.context.editor.getElementByKey(nodeKey);
if (nodeDOM && nodeDOM.nodeName === 'SPAN') {
nodeDOM = nodeDOM.firstElementChild as HTMLElement;
}
if (nodeDOM) { if (nodeDOM) {
this.showForNode(node, nodeDOM); this.showForNode(node, nodeDOM);
@ -51,7 +46,19 @@ class NodeResizer {
} }
} }
onTargetDOMLoad(): void { protected getTargetDOM(targetNode: LexicalNode|null): HTMLElement|null {
if (targetNode == null) {
return null;
}
let nodeDOM = this.context.editor.getElementByKey(targetNode.__key)
if (nodeDOM && nodeDOM.nodeName === 'SPAN') {
nodeDOM = nodeDOM.firstElementChild as HTMLElement;
}
return nodeDOM;
}
protected onTargetDOMLoad(): void {
this.updateResizerPosition(); this.updateResizerPosition();
} }
@ -62,7 +69,7 @@ class NodeResizer {
protected showForNode(node: NodeHasSize&LexicalNode, targetDOM: HTMLElement) { protected showForNode(node: NodeHasSize&LexicalNode, targetDOM: HTMLElement) {
this.resizerDOM = this.buildDOM(); this.resizerDOM = this.buildDOM();
this.targetDOM = targetDOM; this.targetNode = node;
let ghost = el('span', {class: 'editor-node-resizer-ghost'}); let ghost = el('span', {class: 'editor-node-resizer-ghost'});
if ($isImageNode(node)) { if ($isImageNode(node)) {
@ -83,12 +90,13 @@ class NodeResizer {
} }
protected updateResizerPosition() { protected updateResizerPosition() {
if (!this.resizerDOM || !this.targetDOM) { const targetDOM = this.getTargetDOM(this.targetNode);
if (!this.resizerDOM || !targetDOM) {
return; return;
} }
const scrollAreaRect = this.scrollContainer.getBoundingClientRect(); const scrollAreaRect = this.scrollContainer.getBoundingClientRect();
const nodeRect = this.targetDOM.getBoundingClientRect(); const nodeRect = targetDOM.getBoundingClientRect();
const top = nodeRect.top - (scrollAreaRect.top - this.scrollContainer.scrollTop); const top = nodeRect.top - (scrollAreaRect.top - this.scrollContainer.scrollTop);
const left = nodeRect.left - scrollAreaRect.left; const left = nodeRect.left - scrollAreaRect.left;
@ -110,7 +118,7 @@ class NodeResizer {
protected hide() { protected hide() {
this.mouseTracker?.teardown(); this.mouseTracker?.teardown();
this.resizerDOM?.remove(); this.resizerDOM?.remove();
this.targetDOM = null; this.targetNode = null;
this.activeSelection = ''; this.activeSelection = '';
this.loadAbortController.abort(); this.loadAbortController.abort();
} }
@ -126,7 +134,7 @@ class NodeResizer {
}, handleElems); }, handleElems);
} }
setupTracker(container: HTMLElement, node: NodeHasSize, nodeDOM: HTMLElement): MouseDragTracker { setupTracker(container: HTMLElement, node: NodeHasSize&LexicalNode, 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;
@ -179,10 +187,13 @@ class NodeResizer {
_this.context.editor.update(() => { _this.context.editor.update(() => {
node.setWidth(size.width); node.setWidth(size.width);
node.setHeight(hasHeight ? size.height : 0); node.setHeight(hasHeight ? size.height : 0);
_this.context.manager.triggerLayoutUpdate(); }, {
requestAnimationFrame(() => { onUpdate: () => {
_this.updateResizerPosition(); requestAnimationFrame(() => {
}) _this.context.manager.triggerLayoutUpdate();
_this.updateResizerPosition();
});
}
}); });
_this.resizerDOM?.classList.remove('active'); _this.resizerDOM?.classList.remove('active');
} }

View File

@ -52,12 +52,19 @@ export type StyleMap = Map<string, string>;
/** /**
* Creates a map from an element's styles. * Creates a map from an element's styles.
* Uses direct attribute value string handling since attempting to iterate * Uses direct attribute value string handling since attempting to iterate
* over .style will expand out any shorthand properties (like 'padding') making * over .style will expand out any shorthand properties (like 'padding')
* rather than being representative of the actual properties set. * rather than being representative of the actual properties set.
*/ */
export function extractStyleMapFromElement(element: HTMLElement): StyleMap { export function extractStyleMapFromElement(element: HTMLElement): StyleMap {
const map: StyleMap = new Map();
const styleText= element.getAttribute('style') || ''; const styleText= element.getAttribute('style') || '';
return styleStringToStyleMap(styleText);
}
/**
* Convert string-formatted styles into a StyleMap.
*/
export function styleStringToStyleMap(styleText: string): StyleMap {
const map: StyleMap = new Map();
const rules = styleText.split(';'); const rules = styleText.split(';');
for (const rule of rules) { for (const rule of rules) {
@ -72,6 +79,17 @@ export function extractStyleMapFromElement(element: HTMLElement): StyleMap {
return map; return map;
} }
/**
* Convert a StyleMap into inline string style text.
*/
export function styleMapToStyleString(map: StyleMap): string {
const parts = [];
for (const [style, value] of map.entries()) {
parts.push(`${style}:${value}`);
}
return parts.join(';');
}
export function setOrRemoveAttribute(element: HTMLElement, name: string, value: string|null|undefined) { export function setOrRemoveAttribute(element: HTMLElement, name: string, value: string|null|undefined) {
if (value) { if (value) {
element.setAttribute(name, value); element.setAttribute(name, value);

View File

@ -454,7 +454,7 @@ body.editor-is-fullscreen {
.editor-media-wrap { .editor-media-wrap {
display: inline-block; display: inline-block;
cursor: not-allowed; cursor: not-allowed;
iframe { iframe, video {
pointer-events: none; pointer-events: none;
} }
&.align-left { &.align-left {