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";
export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record<string, any> = {}): SimpleWysiwygEditorInterface {
const config: CreateEditorArgs = {
registerTableSelectionHandler(editor),
registerTaskListHandler(editor, editArea),
registerDropPasteHandling(context),
+ registerImageResizer(context),
);
listenToCommonEvents(editor);
import {
- DecoratorNode,
DOMConversion,
DOMConversionMap,
- DOMConversionOutput,
+ DOMConversionOutput, ElementNode,
LexicalEditor, LexicalNode,
- SerializedLexicalNode,
Spread
} from "lexical";
import type {EditorConfig} from "lexical/LexicalEditor";
-import {EditorDecoratorAdapter} from "../ui/framework/decorator";
-import {el} from "../utils/dom";
import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common";
+import {$selectSingleNode} from "../utils/selection";
+import {SerializedElementNode} from "lexical/nodes/LexicalElementNode";
export interface ImageNodeOptions {
alt?: string;
width: number;
height: number;
alignment: CommonBlockAlignment;
-}, SerializedLexicalNode>
+}, SerializedElementNode>
-export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
+export class ImageNode extends ElementNode {
__src: string = '';
__alt: string = '';
__width: number = 0;
}
static clone(node: ImageNode): ImageNode {
- return new ImageNode(node.__src, {
+ const newNode = new ImageNode(node.__src, {
alt: node.__alt,
width: node.__width,
height: node.__height,
});
+ newNode.__alignment = node.__alignment;
+ return newNode;
}
constructor(src: string, options: ImageNodeOptions, key?: string) {
return true;
}
- decorate(editor: LexicalEditor, config: EditorConfig): EditorDecoratorAdapter {
- return {
- type: 'image',
- getNode: () => this,
- };
- }
-
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
const element = document.createElement('img');
element.setAttribute('src', this.__src);
element.classList.add('align-' + this.__alignment);
}
- return el('span', {class: 'editor-image-wrap'}, [
- element,
- ]);
+ element.addEventListener('click', e => {
+ _editor.update(() => {
+ $selectSingleNode(this);
+ });
+ });
+
+ return element;
}
updateDOM(prevNode: ImageNode, dom: HTMLElement) {
- const image = dom.querySelector('img');
- if (!image) return false;
-
if (prevNode.__src !== this.__src) {
- image.setAttribute('src', this.__src);
+ dom.setAttribute('src', this.__src);
}
if (prevNode.__width !== this.__width) {
if (this.__width) {
- image.setAttribute('width', String(this.__width));
+ dom.setAttribute('width', String(this.__width));
} else {
- image.removeAttribute('width');
+ dom.removeAttribute('width');
}
}
if (prevNode.__height !== this.__height) {
if (this.__height) {
- image.setAttribute('height', String(this.__height));
+ dom.setAttribute('height', String(this.__height));
} else {
- image.removeAttribute('height');
+ dom.removeAttribute('height');
}
}
if (prevNode.__alt !== this.__alt) {
if (this.__alt) {
- image.setAttribute('alt', String(this.__alt));
+ dom.setAttribute('alt', String(this.__alt));
} else {
- image.removeAttribute('alt');
+ dom.removeAttribute('alt');
}
}
if (prevNode.__alignment !== this.__alignment) {
if (prevNode.__alignment) {
- image.classList.remove('align-' + prevNode.__alignment);
+ dom.classList.remove('align-' + prevNode.__alignment);
}
if (this.__alignment) {
- image.classList.add('align-' + this.__alignment);
+ dom.classList.add('align-' + this.__alignment);
}
}
exportJSON(): SerializedImageNode {
return {
+ ...super.exportJSON(),
type: 'image',
version: 1,
src: this.__src,
## Main Todo
-- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
- Media resize support (like images)
- Mac: Shortcut support via command.
- Color picker support in table form color fields
- Table caption text support
+- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
## Bugs
-- Image alignment in editor dodgy due to wrapper.
- Can't select iframe embeds by themselves. (click enters iframe)
-- Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node.
- 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).
+++ /dev/null
-import {EditorDecorator} from "../framework/decorator";
-import {$createNodeSelection, $setSelection} from "lexical";
-import {EditorUiContext} from "../framework/core";
-import {ImageNode} from "../../nodes/image";
-import {MouseDragTracker, MouseDragTrackerDistance} from "../framework/helpers/mouse-drag-tracker";
-import {$selectSingleNode} from "../../utils/selection";
-import {el} from "../../utils/dom";
-
-
-export class ImageDecorator extends EditorDecorator {
- protected dom: HTMLElement|null = null;
- protected dragLastMouseUp: number = 0;
-
- buildDOM(context: EditorUiContext) {
- let handleElems: HTMLElement[] = [];
- const decorateEl = el('div', {
- class: 'editor-image-decorator',
- }, []);
- let selected = false;
- let tracker: MouseDragTracker|null = null;
-
- const windowClick = (event: MouseEvent) => {
- if (!decorateEl.contains(event.target as Node) && (Date.now() - this.dragLastMouseUp > 100)) {
- unselect();
- }
- };
-
- const select = () => {
- if (selected) {
- return;
- }
-
- selected = true;
- decorateEl.classList.add('selected');
- window.addEventListener('click', windowClick);
-
- const handleClasses = ['nw', 'ne', 'se', 'sw'];
- handleElems = handleClasses.map(c => {
- return el('div', {class: `editor-image-decorator-handle ${c}`});
- });
- decorateEl.append(...handleElems);
- tracker = this.setupTracker(decorateEl, context);
-
- context.editor.update(() => {
- $selectSingleNode(this.getNode());
- });
- };
-
- const unselect = () => {
- selected = false;
- decorateEl.classList.remove('selected');
- window.removeEventListener('click', windowClick);
- tracker?.teardown();
- for (const el of handleElems) {
- el.remove();
- }
- };
-
- decorateEl.addEventListener('click', (event) => {
- select();
- });
-
- return decorateEl;
- }
-
- render(context: EditorUiContext): HTMLElement {
- if (this.dom) {
- return this.dom;
- }
-
- this.dom = this.buildDOM(context);
- return this.dom;
- }
-
- setupTracker(container: HTMLElement, context: EditorUiContext): MouseDragTracker {
- let startingWidth: number = 0;
- let startingHeight: number = 0;
- let startingRatio: number = 0;
- let hasHeight = false;
- let firstChange = true;
- let node: ImageNode = this.getNode() as ImageNode;
- let _this = this;
- let flipXChange: boolean = false;
- let flipYChange: boolean = false;
-
- return new MouseDragTracker(container, '.editor-image-decorator-handle', {
- down(event: MouseEvent, handle: HTMLElement) {
- context.editor.getEditorState().read(() => {
- startingWidth = node.getWidth() || startingWidth;
- startingHeight = node.getHeight() || startingHeight;
- if (node.getHeight()) {
- hasHeight = true;
- }
- startingRatio = startingWidth / startingHeight;
- });
-
- flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw');
- flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne');
- },
- move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) {
- let xChange = distance.x;
- if (flipXChange) {
- xChange = 0 - xChange;
- }
- let yChange = distance.y;
- if (flipYChange) {
- yChange = 0 - yChange;
- }
- const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2));
- const increase = xChange + yChange > 0;
- const directedChange = increase ? balancedChange : 0-balancedChange;
- const newWidth = Math.max(5, Math.round(startingWidth + directedChange));
- let newHeight = 0;
- if (hasHeight) {
- newHeight = newWidth * startingRatio;
- }
-
- const updateOptions = firstChange ? {} : {tag: 'history-merge'};
- context.editor.update(() => {
- const node = _this.getNode() as ImageNode;
- node.setWidth(newWidth);
- node.setHeight(newHeight);
- }, updateOptions);
- firstChange = false;
- },
- up() {
- _this.dragLastMouseUp = Date.now();
- }
- });
- }
-
-}
\ No newline at end of file
-import {BaseSelection, LexicalEditor} from "lexical";
+import {$isElementNode, BaseSelection, LexicalEditor} from "lexical";
import {EditorButtonDefinition} from "../../framework/buttons";
import alignLeftIcon from "@icons/editor/align-left.svg";
import {EditorUiContext} from "../../framework/core";
import alignJustifyIcon from "@icons/editor/align-justify.svg";
import {
$getBlockElementNodesInSelection,
- $getDecoratorNodesInSelection,
- $selectionContainsAlignment, getLastSelection
+ $selectionContainsAlignment, $selectSingleNode, $toggleSelection, getLastSelection
} from "../../../utils/selection";
import {CommonBlockAlignment} from "../../../nodes/_common";
import {nodeHasAlignment} from "../../../utils/nodes";
function setAlignmentForSection(editor: LexicalEditor, alignment: CommonBlockAlignment): void {
const selection = getLastSelection(editor);
const selectionNodes = selection?.getNodes() || [];
- const decorators = $getDecoratorNodesInSelection(selection);
- // Handle decorator node selection alignment
- if (selectionNodes.length === 1 && decorators.length === 1 && nodeHasAlignment(decorators[0])) {
- decorators[0].setAlignment(alignment);
- console.log('setting for decorator!');
+ // Handle inline node selection alignment
+ if (selectionNodes.length === 1 && $isElementNode(selectionNodes[0]) && selectionNodes[0].isInline() && nodeHasAlignment(selectionNodes[0])) {
+ selectionNodes[0].setAlignment(alignment);
+ $selectSingleNode(selectionNodes[0]);
+ $toggleSelection(editor);
return;
}
node.setAlignment(alignment)
}
}
+ $toggleSelection(editor);
}
export const alignLeft: EditorButtonDefinition = {
--- /dev/null
+import {BaseSelection,} from "lexical";
+import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker";
+import {el} from "../../../utils/dom";
+import {$isImageNode, ImageNode} from "../../../nodes/image";
+import {EditorUiContext} from "../core";
+
+class ImageResizer {
+ protected context: EditorUiContext;
+ protected dom: HTMLElement|null = null;
+ protected scrollContainer: HTMLElement;
+
+ protected mouseTracker: MouseDragTracker|null = null;
+ protected activeSelection: string = '';
+
+ constructor(context: EditorUiContext) {
+ this.context = context;
+ this.scrollContainer = context.scrollDOM;
+
+ this.onSelectionChange = this.onSelectionChange.bind(this);
+ context.manager.onSelectionChange(this.onSelectionChange);
+ }
+
+ onSelectionChange(selection: BaseSelection|null) {
+ const nodes = selection?.getNodes() || [];
+ if (this.activeSelection) {
+ 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 (imageDOM) {
+ this.showForImage(imageNode, imageDOM);
+ }
+ }
+ }
+
+ teardown() {
+ this.context.manager.offSelectionChange(this.onSelectionChange);
+ this.hide();
+ }
+
+ protected showForImage(node: ImageNode, dom: HTMLElement) {
+ this.dom = this.buildDOM();
+
+ const ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-image-resizer-ghost'});
+ this.dom.append(ghost);
+
+ this.context.scrollDOM.append(this.dom);
+ this.updateDOMPosition(dom);
+
+ this.mouseTracker = this.setupTracker(this.dom, node, dom);
+ this.activeSelection = node.getKey();
+ }
+
+ protected updateDOMPosition(imageDOM: 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';
+ }
+
+ protected updateDOMSize(width: number, height: number): void {
+ if (!this.dom) {
+ return;
+ }
+
+ this.dom.style.width = width + 'px';
+ this.dom.style.height = height + 'px';
+ }
+
+ protected hide() {
+ this.mouseTracker?.teardown();
+ this.dom?.remove();
+ this.activeSelection = '';
+ }
+
+ 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-image-resizer',
+ }, handleElems);
+ }
+
+ setupTracker(container: HTMLElement, node: ImageNode, imageDOM: HTMLElement): MouseDragTracker {
+ let startingWidth: number = 0;
+ let startingHeight: number = 0;
+ let startingRatio: number = 0;
+ let hasHeight = false;
+ let _this = this;
+ let flipXChange: boolean = false;
+ let flipYChange: boolean = false;
+
+ const calculateSize = (distance: MouseDragTrackerDistance): {width: number, height: number} => {
+ let xChange = distance.x;
+ if (flipXChange) {
+ xChange = 0 - xChange;
+ }
+ let yChange = distance.y;
+ if (flipYChange) {
+ yChange = 0 - yChange;
+ }
+
+ const balancedChange = Math.sqrt(Math.pow(Math.abs(xChange), 2) + Math.pow(Math.abs(yChange), 2));
+ const increase = xChange + yChange > 0;
+ const directedChange = increase ? balancedChange : 0-balancedChange;
+ const newWidth = Math.max(5, Math.round(startingWidth + directedChange));
+ const newHeight = newWidth * startingRatio;
+
+ return {width: newWidth, height: newHeight};
+ };
+
+ return new MouseDragTracker(container, '.editor-image-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;
+ if (node.getHeight()) {
+ hasHeight = true;
+ }
+ startingRatio = startingWidth / startingHeight;
+ });
+
+ flipXChange = handle.classList.contains('nw') || handle.classList.contains('sw');
+ flipYChange = handle.classList.contains('nw') || handle.classList.contains('ne');
+ },
+ move(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) {
+ const size = calculateSize(distance);
+ _this.updateDOMSize(size.width, size.height);
+ },
+ up(event: MouseEvent, handle: HTMLElement, distance: MouseDragTrackerDistance) {
+ const size = calculateSize(distance);
+ _this.context.editor.update(() => {
+ node.setWidth(size.width);
+ node.setHeight(hasHeight ? size.height : 0);
+ _this.context.manager.triggerLayoutUpdate();
+ requestAnimationFrame(() => {
+ _this.updateDOMPosition(imageDOM);
+ })
+ });
+ _this.dom?.classList.remove('active');
+ }
+ });
+ }
+}
+
+
+export function registerImageResizer(context: EditorUiContext): (() => void) {
+ const resizer = new ImageResizer(context);
+
+ return () => {
+ resizer.teardown();
+ };
+}
\ No newline at end of file
this.selectionChangeHandlers.delete(handler);
}
+ triggerLayoutUpdate(): void {
+ window.requestAnimationFrame(() => {
+ for (const toolbar of this.activeContextToolbars) {
+ toolbar.updatePosition();
+ }
+ });
+ }
+
protected updateContextToolbars(update: EditorUiStateUpdate): void {
for (let i = this.activeContextToolbars.length - 1; i >= 0; i--) {
const toolbar = this.activeContextToolbars[i];
}
protected setupEventListeners(context: EditorUiContext) {
- const updateToolbars = (event: Event) => {
- for (const toolbar of this.activeContextToolbars) {
- toolbar.updatePosition();
- }
- };
-
- window.addEventListener('scroll', updateToolbars, {capture: true, passive: true});
- window.addEventListener('resize', updateToolbars, {passive: true});
+ const layoutUpdate = this.triggerLayoutUpdate.bind(this);
+ window.addEventListener('scroll', layoutUpdate, {capture: true, passive: true});
+ window.addEventListener('resize', layoutUpdate, {passive: true});
}
}
\ No newline at end of file
getMainEditorFullToolbar, getTableToolbarContent
} from "./toolbars";
import {EditorUIManager} from "./framework/manager";
-import {ImageDecorator} from "./decorators/image";
import {EditorUiContext} from "./framework/core";
import {CodeBlockDecorator} from "./decorators/code-block";
import {DiagramDecorator} from "./decorators/diagram";
});
// Register image decorator listener
- manager.registerDecoratorType('image', ImageDecorator);
manager.registerDecoratorType('code', CodeBlockDecorator);
manager.registerDecoratorType('diagram', DiagramDecorator);
import {
$createNodeSelection,
- $createParagraphNode,
+ $createParagraphNode, $createRangeSelection,
$getRoot,
$getSelection, $isDecoratorNode,
$isElementNode,
$setSelection(nodeSelection);
}
+export function $toggleSelection(editor: LexicalEditor) {
+ const lastSelection = getLastSelection(editor);
+
+ if (lastSelection) {
+ window.requestAnimationFrame(() => {
+ editor.update(() => {
+ $setSelection(lastSelection.clone());
+ })
+ });
+ }
+}
+
export function $selectionContainsNode(selection: BaseSelection | null, node: LexicalNode): boolean {
if (!selection) {
return false;
}
export function $selectionContainsAlignment(selection: BaseSelection | null, alignment: CommonBlockAlignment): boolean {
- const nodes = $getBlockElementNodesInSelection(selection);
+
+ const nodes = [
+ ...(selection?.getNodes() || []),
+ ...$getBlockElementNodesInSelection(selection)
+ ];
for (const node of nodes) {
if (nodeHasAlignment(node) && node.getAlignment() === alignment) {
return true;
}
}
.editor-content-wrap {
+ position: relative;
overflow-y: scroll;
}
position: relative;
display: inline-flex;
}
-.editor-image-decorator {
+.editor-image-resizer {
position: absolute;
left: 0;
right: 0;
- width: 100%;
- height: 100%;
display: inline-block;
- &.selected {
- border: 1px dashed var(--editor-color-primary);
- }
+ outline: 2px dashed var(--editor-color-primary);
}
-.editor-image-decorator-handle {
+.editor-image-resizer-handle {
position: absolute;
display: block;
width: 10px;
height: 10px;
border: 2px solid var(--editor-color-primary);
+ z-index: 3;
background-color: #FFF;
user-select: none;
&.nw {
cursor: sw-resize;
}
}
+.editor-image-resizer-ghost {
+ opacity: 0.5;
+ display: none;
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 2;
+ pointer-events: none;
+}
+.editor-image-resizer.active .editor-image-resizer-ghost {
+ display: block;
+}
.editor-table-marker {
position: fixed;