Lexical: Started image resize controls, Defined thorough decorator model

This commit is contained in:
Dan Brown 2024-06-05 13:04:49 +01:00
parent a74e04141c
commit ba871ec46a
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
6 changed files with 244 additions and 16 deletions

View File

@ -9,6 +9,7 @@ import {
} from "lexical"; } from "lexical";
import type {EditorConfig} from "lexical/LexicalEditor"; import type {EditorConfig} from "lexical/LexicalEditor";
import {el} from "../helpers"; import {el} from "../helpers";
import {EditorDecoratorAdapter} from "../ui/framework/decorator";
export interface ImageNodeOptions { export interface ImageNodeOptions {
alt?: string; alt?: string;
@ -23,7 +24,7 @@ export type SerializedImageNode = Spread<{
height: number; height: number;
}, SerializedLexicalNode> }, SerializedLexicalNode>
export class ImageNode extends DecoratorNode<HTMLElement> { export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
__src: string = ''; __src: string = '';
__alt: string = ''; __alt: string = '';
__width: number = 0; __width: number = 0;
@ -79,6 +80,7 @@ export class ImageNode extends DecoratorNode<HTMLElement> {
setWidth(width: number): void { setWidth(width: number): void {
const self = this.getWritable(); const self = this.getWritable();
self.__width = width; self.__width = width;
console.log('widrg', width)
} }
getWidth(): number { getWidth(): number {
@ -90,17 +92,16 @@ export class ImageNode extends DecoratorNode<HTMLElement> {
return true; return true;
} }
decorate(editor: LexicalEditor, config: EditorConfig): HTMLElement { decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter {
console.log('decorate!'); return {
return el('div', { type: 'image',
class: 'editor-image-decorator', getNode: () => this,
}, ['decoration!!!']); };
} }
createDOM(_config: EditorConfig, _editor: LexicalEditor) { createDOM(_config: EditorConfig, _editor: LexicalEditor) {
const element = document.createElement('img'); const element = document.createElement('img');
element.setAttribute('src', this.__src); element.setAttribute('src', this.__src);
element.textContent
if (this.__width) { if (this.__width) {
element.setAttribute('width', String(this.__width)); element.setAttribute('width', String(this.__width));
@ -116,9 +117,38 @@ export class ImageNode extends DecoratorNode<HTMLElement> {
]); ]);
} }
updateDOM(prevNode: unknown, dom: HTMLElement) { updateDOM(prevNode: ImageNode, dom: HTMLElement) {
// Returning false tells Lexical that this node does not need its const image = dom.querySelector('img');
// DOM element replacing with a new copy from createDOM. if (!image) return false;
if (prevNode.__src !== this.__src) {
image.setAttribute('src', this.__src);
}
if (prevNode.__width !== this.__width) {
if (this.__width) {
image.setAttribute('width', String(this.__width));
} else {
image.removeAttribute('width');
}
}
if (prevNode.__height !== this.__height) {
if (this.__height) {
image.setAttribute('height', String(this.__height));
} else {
image.removeAttribute('height');
}
}
if (prevNode.__alt !== this.__alt) {
if (this.__alt) {
image.setAttribute('alt', String(this.__alt));
} else {
image.removeAttribute('alt');
}
}
return false; return false;
} }

View File

@ -0,0 +1,91 @@
import {EditorDecorator} from "../framework/decorator";
import {el} from "../../helpers";
import {$createNodeSelection, $setSelection} from "lexical";
import {EditorUiContext} from "../framework/core";
import {ImageNode} from "../../nodes/image";
export class ImageDecorator extends EditorDecorator {
protected dom: HTMLElement|null = null;
buildDOM(context: EditorUiContext) {
const handleClasses = ['nw', 'ne', 'se', 'sw'];
const handleEls = handleClasses.map(c => {
return el('div', {class: `editor-image-decorator-handle ${c}`});
});
const decorateEl = el('div', {
class: 'editor-image-decorator',
}, handleEls);
const windowClick = (event: MouseEvent) => {
if (!decorateEl.contains(event.target as Node)) {
unselect();
}
};
const select = () => {
decorateEl.classList.add('selected');
window.addEventListener('click', windowClick);
};
const unselect = () => {
decorateEl.classList.remove('selected');
window.removeEventListener('click', windowClick);
};
decorateEl.addEventListener('click', (event) => {
context.editor.update(() => {
const nodeSelection = $createNodeSelection();
nodeSelection.add(this.getNode().getKey());
$setSelection(nodeSelection);
});
select();
});
decorateEl.addEventListener('mousedown', (event: MouseEvent) => {
const handle = (event.target as Element).closest('.editor-image-decorator-handle');
if (handle) {
this.startHandlingResize(handle, event, context);
}
});
return decorateEl;
}
render(context: EditorUiContext): HTMLElement {
if (this.dom) {
return this.dom;
}
this.dom = this.buildDOM(context);
return this.dom;
}
startHandlingResize(element: Node, event: MouseEvent, context: EditorUiContext) {
const startingX = event.screenX;
const startingY = event.screenY;
const mouseMoveListener = (event: MouseEvent) => {
const xChange = event.screenX - startingX;
const yChange = event.screenY - startingY;
console.log({ xChange, yChange });
context.editor.update(() => {
const node = this.getNode() as ImageNode;
node.setWidth(node.getWidth() + xChange);
node.setHeight(node.getHeight() + yChange);
});
};
const mouseUpListener = (event: MouseEvent) => {
window.removeEventListener('mousemove', mouseMoveListener);
window.removeEventListener('mouseup', mouseUpListener);
}
window.addEventListener('mousemove', mouseMoveListener);
window.addEventListener('mouseup', mouseUpListener);
}
}

View File

@ -0,0 +1,32 @@
import {EditorUiContext} from "./core";
import {LexicalNode} from "lexical";
export interface EditorDecoratorAdapter {
type: string;
getNode(): LexicalNode;
}
export abstract class EditorDecorator {
protected node: LexicalNode | null = null;
protected context: EditorUiContext;
constructor(context: EditorUiContext) {
this.context = context;
}
protected getNode(): LexicalNode {
if (!this.node) {
throw new Error('Attempted to get use node without it being set');
}
return this.node;
}
setNode(node: LexicalNode) {
this.node = node;
}
abstract render(context: EditorUiContext): HTMLElement;
}

View File

@ -1,10 +1,13 @@
import {EditorFormModal, EditorFormModalDefinition} from "./modals"; import {EditorFormModal, EditorFormModalDefinition} from "./modals";
import {EditorUiContext} from "./core"; import {EditorUiContext} from "./core";
import {EditorDecorator} from "./decorator";
export class EditorUIManager { export class EditorUIManager {
protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {}; protected modalDefinitionsByKey: Record<string, EditorFormModalDefinition> = {};
protected decoratorConstructorsByType: Record<string, typeof EditorDecorator> = {};
protected decoratorInstancesByNodeKey: Record<string, EditorDecorator> = {};
protected context: EditorUiContext|null = null; protected context: EditorUiContext|null = null;
setContext(context: EditorUiContext) { setContext(context: EditorUiContext) {
@ -26,7 +29,7 @@ export class EditorUIManager {
createModal(key: string): EditorFormModal { createModal(key: string): EditorFormModal {
const modalDefinition = this.modalDefinitionsByKey[key]; const modalDefinition = this.modalDefinitionsByKey[key];
if (!modalDefinition) { if (!modalDefinition) {
console.error(`Attempted to show modal of key [${key}] but no modal registered for that key`); throw new Error(`Attempted to show modal of key [${key}] but no modal registered for that key`);
} }
const modal = new EditorFormModal(modalDefinition); const modal = new EditorFormModal(modalDefinition);
@ -35,4 +38,23 @@ export class EditorUIManager {
return modal; return modal;
} }
registerDecoratorType(type: string, decorator: typeof EditorDecorator) {
this.decoratorConstructorsByType[type] = decorator;
}
getDecorator(decoratorType: string, nodeKey: string): EditorDecorator {
if (this.decoratorInstancesByNodeKey[nodeKey]) {
return this.decoratorInstancesByNodeKey[nodeKey];
}
const decoratorClass = this.decoratorConstructorsByType[decoratorType];
if (!decoratorClass) {
throw new Error(`Attempted to use decorator of type [${decoratorType}] but not decorator registered for that type`);
}
// @ts-ignore
const decorator = new decoratorClass(nodeKey);
this.decoratorInstancesByNodeKey[nodeKey] = decorator;
return decorator;
}
} }

View File

@ -9,7 +9,8 @@ import {EditorUIManager} from "./framework/manager";
import {link as linkFormDefinition} from "./defaults/form-definitions"; import {link as linkFormDefinition} from "./defaults/form-definitions";
import {DecoratorListener} from "lexical/LexicalEditor"; import {DecoratorListener} from "lexical/LexicalEditor";
import type {NodeKey} from "lexical/LexicalNode"; import type {NodeKey} from "lexical/LexicalNode";
import {el} from "../helpers"; import {EditorDecoratorAdapter} from "./framework/decorator";
import {ImageDecorator} from "./decorators/image";
export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) { export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) {
const manager = new EditorUIManager(); const manager = new EditorUIManager();
@ -33,11 +34,15 @@ export function buildEditorUI(element: HTMLElement, editor: LexicalEditor) {
// Register decorator listener // Register decorator listener
// Maybe move to manager? // Maybe move to manager?
const domDecorateListener: DecoratorListener<HTMLElement> = (decorator: Record<NodeKey, HTMLElement>) => { manager.registerDecoratorType('image', ImageDecorator);
const keys = Object.keys(decorator); const domDecorateListener: DecoratorListener<EditorDecoratorAdapter> = (decorators: Record<NodeKey, EditorDecoratorAdapter>) => {
const keys = Object.keys(decorators);
for (const key of keys) { for (const key of keys) {
const decoratedEl = editor.getElementByKey(key); const decoratedEl = editor.getElementByKey(key);
const decoratorEl = decorator[key]; const adapter = decorators[key];
const decorator = manager.getDecorator(adapter.type, key);
decorator.setNode(adapter.getNode());
const decoratorEl = decorator.render(context);
if (decoratedEl) { if (decoratedEl) {
decoratedEl.append(decoratorEl); decoratedEl.append(decoratorEl);
} }

View File

@ -1,3 +1,8 @@
// Common variables
:root {
--editor-color-primary: #206ea7;
}
// Main UI elements // Main UI elements
.editor-toolbar-main { .editor-toolbar-main {
display: flex; display: flex;
@ -72,4 +77,47 @@
} }
.editor-modal-title { .editor-modal-title {
font-weight: 700; font-weight: 700;
} }
// In-editor elements
.editor-image-wrap {
position: relative;
display: inline-flex;
}
.editor-image-decorator {
display: inline-block;
position: absolute;
border: 1px solid var(--editor-color-primary);
left: 0;
right: 0;
width: 100%;
height: 100%;
}
.editor-image-decorator-handle {
position: absolute;
display: block;
width: 10px;
height: 10px;
background-color: var(--editor-color-primary);
user-select: none;
&.nw {
inset-inline-start: -5px;
inset-block-start: -5px;
cursor: nw-resize;
}
&.ne {
inset-inline-end: -5px;
inset-block-start: -5px;
cursor: ne-resize;
}
&.se {
inset-inline-end: -5px;
inset-block-end: -5px;
cursor: se-resize;
}
&.sw {
inset-inline-start: -5px;
inset-block-end: -5px;
cursor: sw-resize;
}
}