mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Lexical: Added media resize support via drag handles
This commit is contained in:
parent
e5b6d28bca
commit
bed2c29a33
@ -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);
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
|
@ -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
|
|
@ -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();
|
||||||
|
@ -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>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user