mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Lexical: Started image resize controls, Defined thorough decorator model
This commit is contained in:
parent
a74e04141c
commit
ba871ec46a
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
91
resources/js/wysiwyg/ui/decorators/image.ts
Normal file
91
resources/js/wysiwyg/ui/decorators/image.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
32
resources/js/wysiwyg/ui/framework/decorator.ts
Normal file
32
resources/js/wysiwyg/ui/framework/decorator.ts
Normal 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;
|
||||||
|
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user