mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-07-16 05:33:49 +03:00
Merge pull request #5653 from BookStackApp/v25-05-1-lexical
Lexical Fixes for v25.05.1
This commit is contained in:
@@ -84,7 +84,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
|
||||
|
||||
// @ts-ignore
|
||||
window.debugEditorState = () => {
|
||||
console.log(editor.getEditorState().toJSON());
|
||||
return editor.getEditorState().toJSON();
|
||||
};
|
||||
|
||||
registerCommonNodeMutationListeners(context);
|
||||
|
||||
@@ -355,6 +355,7 @@ function onSelectionChange(
|
||||
lastNode instanceof ParagraphNode &&
|
||||
lastNode.getChildrenSize() === 0
|
||||
) {
|
||||
selection.format = lastNode.getTextFormat();
|
||||
selection.style = lastNode.getTextStyle();
|
||||
} else {
|
||||
selection.format = 0;
|
||||
|
||||
@@ -1069,6 +1069,7 @@ describe('LexicalEditor tests', () => {
|
||||
__prev: null,
|
||||
__size: 1,
|
||||
__style: '',
|
||||
__textFormat: 0,
|
||||
__textStyle: '',
|
||||
__type: 'paragraph',
|
||||
});
|
||||
@@ -1149,6 +1150,7 @@ describe('LexicalEditor tests', () => {
|
||||
__prev: null,
|
||||
__size: 1,
|
||||
__style: '',
|
||||
__textFormat: 0,
|
||||
__textStyle: '',
|
||||
__type: 'paragraph',
|
||||
});
|
||||
|
||||
@@ -76,6 +76,7 @@ describe('LexicalEditorState tests', () => {
|
||||
__prev: null,
|
||||
__size: 1,
|
||||
__style: '',
|
||||
__textFormat: 0,
|
||||
__textStyle: '',
|
||||
__type: 'paragraph',
|
||||
});
|
||||
@@ -111,7 +112,7 @@ describe('LexicalEditorState tests', () => {
|
||||
});
|
||||
|
||||
expect(JSON.stringify(editor.getEditorState().toJSON())).toEqual(
|
||||
`{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"root","version":1}}`,
|
||||
`{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textFormat":0,"textStyle":""}],"direction":null,"type":"root","version":1}}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -38,6 +38,7 @@ import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
|
||||
import {EditorUiContext} from "../../../../ui/framework/core";
|
||||
import {EditorUIManager} from "../../../../ui/framework/manager";
|
||||
import {ImageNode} from "@lexical/rich-text/LexicalImageNode";
|
||||
import {MediaNode} from "@lexical/rich-text/LexicalMediaNode";
|
||||
|
||||
type TestEnv = {
|
||||
readonly container: HTMLDivElement;
|
||||
@@ -487,6 +488,7 @@ export function createTestContext(): EditorUiContext {
|
||||
theme: {},
|
||||
nodes: [
|
||||
ImageNode,
|
||||
MediaNode,
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
} from '../LexicalNode';
|
||||
import type {RangeSelection} from 'lexical';
|
||||
import {RangeSelection, TEXT_TYPE_TO_FORMAT, TextFormatType} from 'lexical';
|
||||
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
@@ -36,6 +36,7 @@ import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} f
|
||||
|
||||
export type SerializedParagraphNode = Spread<
|
||||
{
|
||||
textFormat: number;
|
||||
textStyle: string;
|
||||
},
|
||||
SerializedCommonBlockNode
|
||||
@@ -45,10 +46,12 @@ export type SerializedParagraphNode = Spread<
|
||||
export class ParagraphNode extends CommonBlockNode {
|
||||
['constructor']!: KlassConstructor<typeof ParagraphNode>;
|
||||
/** @internal */
|
||||
__textFormat: number;
|
||||
__textStyle: string;
|
||||
|
||||
constructor(key?: NodeKey) {
|
||||
super(key);
|
||||
this.__textFormat = 0;
|
||||
this.__textStyle = '';
|
||||
}
|
||||
|
||||
@@ -56,6 +59,22 @@ export class ParagraphNode extends CommonBlockNode {
|
||||
return 'paragraph';
|
||||
}
|
||||
|
||||
getTextFormat(): number {
|
||||
const self = this.getLatest();
|
||||
return self.__textFormat;
|
||||
}
|
||||
|
||||
setTextFormat(type: number): this {
|
||||
const self = this.getWritable();
|
||||
self.__textFormat = type;
|
||||
return self;
|
||||
}
|
||||
|
||||
hasTextFormat(type: TextFormatType): boolean {
|
||||
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
|
||||
return (this.getTextFormat() & formatFlag) !== 0;
|
||||
}
|
||||
|
||||
getTextStyle(): string {
|
||||
const self = this.getLatest();
|
||||
return self.__textStyle;
|
||||
@@ -73,6 +92,7 @@ export class ParagraphNode extends CommonBlockNode {
|
||||
|
||||
afterCloneFrom(prevNode: this) {
|
||||
super.afterCloneFrom(prevNode);
|
||||
this.__textFormat = prevNode.__textFormat;
|
||||
this.__textStyle = prevNode.__textStyle;
|
||||
copyCommonBlockProperties(prevNode, this);
|
||||
}
|
||||
@@ -125,12 +145,14 @@ export class ParagraphNode extends CommonBlockNode {
|
||||
static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode {
|
||||
const node = $createParagraphNode();
|
||||
deserializeCommonBlockNode(serializedNode, node);
|
||||
node.setTextFormat(serializedNode.textFormat);
|
||||
return node;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedParagraphNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
textFormat: this.getTextFormat(),
|
||||
textStyle: this.getTextStyle(),
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
@@ -144,6 +166,7 @@ export class ParagraphNode extends CommonBlockNode {
|
||||
restoreSelection: boolean,
|
||||
): ParagraphNode {
|
||||
const newElement = $createParagraphNode();
|
||||
newElement.setTextFormat(rangeSelection.format);
|
||||
newElement.setTextStyle(rangeSelection.style);
|
||||
const direction = this.getDirection();
|
||||
newElement.setDirection(direction);
|
||||
|
||||
@@ -620,6 +620,7 @@ export class TextNode extends LexicalNode {
|
||||
// HTML content and not have the ability to use CSS classes.
|
||||
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
||||
let {element} = super.exportDOM(editor);
|
||||
const originalElementName = (element?.nodeName || '').toLowerCase()
|
||||
invariant(
|
||||
element !== null && isHTMLElement(element),
|
||||
'Expected TextNode createDOM to always return a HTMLElement',
|
||||
@@ -649,8 +650,8 @@ export class TextNode extends LexicalNode {
|
||||
// This is the only way to properly add support for most clients,
|
||||
// even if it's semantically incorrect to have to resort to using
|
||||
// <b>, <u>, <s>, <i> elements.
|
||||
if (this.hasFormat('bold')) {
|
||||
element = wrapElementWith(element, 'b');
|
||||
if (this.hasFormat('bold') && originalElementName !== 'strong') {
|
||||
element = wrapElementWith(element, 'strong');
|
||||
}
|
||||
if (this.hasFormat('italic')) {
|
||||
element = wrapElementWith(element, 'em');
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('LexicalParagraphNode tests', () => {
|
||||
direction: null,
|
||||
id: '',
|
||||
inset: 0,
|
||||
textFormat: 0,
|
||||
textStyle: '',
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
|
||||
@@ -839,7 +839,7 @@ describe('LexicalTextNode tests', () => {
|
||||
paragraph.append(textNode);
|
||||
|
||||
const html = $generateHtmlFromNodes($getEditor(), null);
|
||||
expect(html).toBe('<p><u><em><b><code spellcheck="false"><strong>hello</strong></code></b></em></u></p>');
|
||||
expect(html).toBe('<p><u><em><strong><code spellcheck="false"><strong>hello</strong></code></strong></em></u></p>');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,13 +8,12 @@ import {
|
||||
} from 'lexical';
|
||||
import type {EditorConfig} from "lexical/LexicalEditor";
|
||||
|
||||
import {el, setOrRemoveAttribute, sizeToPixels} from "../../utils/dom";
|
||||
import {el, setOrRemoveAttribute, sizeToPixels, styleMapToStyleString, styleStringToStyleMap} from "../../utils/dom";
|
||||
import {
|
||||
CommonBlockAlignment, deserializeCommonBlockNode,
|
||||
setCommonBlockPropsFromElement,
|
||||
updateElementWithCommonBlockProps
|
||||
} from "lexical/nodes/common";
|
||||
import {$selectSingleNode} from "../../utils/selection";
|
||||
import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
|
||||
|
||||
export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
|
||||
@@ -46,6 +45,19 @@ function filterAttributes(attributes: Record<string, string>): Record<string, st
|
||||
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 {
|
||||
const node = $createMediaNode(tag);
|
||||
|
||||
@@ -118,7 +130,7 @@ export class MediaNode extends ElementNode {
|
||||
|
||||
getAttributes(): Record<string, string> {
|
||||
const self = this.getLatest();
|
||||
return self.__attributes;
|
||||
return Object.assign({}, self.__attributes);
|
||||
}
|
||||
|
||||
setSources(sources: MediaNodeSource[]) {
|
||||
@@ -128,25 +140,37 @@ export class MediaNode extends ElementNode {
|
||||
|
||||
getSources(): MediaNodeSource[] {
|
||||
const self = this.getLatest();
|
||||
return self.__sources;
|
||||
return self.__sources.map(s => Object.assign({}, s))
|
||||
}
|
||||
|
||||
setSrc(src: string): void {
|
||||
const attrs = Object.assign({}, this.getAttributes());
|
||||
const attrs = this.getAttributes();
|
||||
const sources = this.getSources();
|
||||
|
||||
if (this.__tag ==='object') {
|
||||
attrs.data = src;
|
||||
} if (this.__tag === 'video' && sources.length > 0) {
|
||||
sources[0].src = src;
|
||||
delete attrs.src;
|
||||
if (sources.length > 1) {
|
||||
sources.splice(1, sources.length - 1);
|
||||
}
|
||||
this.setSources(sources);
|
||||
} else {
|
||||
attrs.src = src;
|
||||
}
|
||||
|
||||
this.setAttributes(attrs);
|
||||
}
|
||||
|
||||
setWidthAndHeight(width: string, height: string): void {
|
||||
const attrs = Object.assign(
|
||||
{},
|
||||
let attrs: Record<string, string> = Object.assign(
|
||||
this.getAttributes(),
|
||||
{width, height},
|
||||
);
|
||||
|
||||
attrs = removeStyleFromAttributes(attrs, 'width');
|
||||
attrs = removeStyleFromAttributes(attrs, 'height');
|
||||
this.setAttributes(attrs);
|
||||
}
|
||||
|
||||
@@ -185,8 +209,8 @@ export class MediaNode extends ElementNode {
|
||||
return;
|
||||
}
|
||||
|
||||
const attrs = Object.assign({}, this.getAttributes(), {height});
|
||||
this.setAttributes(attrs);
|
||||
const attrs = Object.assign(this.getAttributes(), {height});
|
||||
this.setAttributes(removeStyleFromAttributes(attrs, 'height'));
|
||||
}
|
||||
|
||||
getHeight(): number {
|
||||
@@ -195,8 +219,9 @@ export class MediaNode extends ElementNode {
|
||||
}
|
||||
|
||||
setWidth(width: number): void {
|
||||
const attrs = Object.assign({}, this.getAttributes(), {width});
|
||||
this.setAttributes(attrs);
|
||||
const existingAttrs = this.getAttributes();
|
||||
const attrs: Record<string, string> = Object.assign(existingAttrs, {width});
|
||||
this.setAttributes(removeStyleFromAttributes(attrs, 'width'));
|
||||
}
|
||||
|
||||
getWidth(): number {
|
||||
@@ -222,15 +247,9 @@ export class MediaNode extends ElementNode {
|
||||
|
||||
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
|
||||
const media = this.createInnerDOM();
|
||||
const wrap = el('span', {
|
||||
return el('span', {
|
||||
class: media.className + ' editor-media-wrap',
|
||||
}, [media]);
|
||||
|
||||
wrap.addEventListener('click', e => {
|
||||
_editor.update(() => $selectSingleNode(this));
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
updateDOM(prevNode: MediaNode, dom: HTMLElement): boolean {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
test('setSrc on video uses sources if existing', () => {
|
||||
const {editor} = createTestContext();
|
||||
editor.updateAndCommit(() => {
|
||||
const mediaMode = $createMediaNode('video');
|
||||
mediaMode.setAttributes({src: 'z'});
|
||||
mediaMode.setSources([{src: 'a', type: 'video'}, {src: 'b', type: 'video'}]);
|
||||
|
||||
mediaMode.setSrc('c');
|
||||
|
||||
expect(mediaMode.getAttributes().src).toBeUndefined();
|
||||
expect(mediaMode.getSources()).toHaveLength(1);
|
||||
expect(mediaMode.getSources()[0].src).toBe('c');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -123,6 +123,7 @@ describe('table selection', () => {
|
||||
__prev: null,
|
||||
__size: 1,
|
||||
__style: '',
|
||||
__textFormat: 0,
|
||||
__textStyle: '',
|
||||
__type: 'paragraph',
|
||||
});
|
||||
|
||||
@@ -92,6 +92,7 @@ export const image: EditorButtonDefinition = {
|
||||
context.editor.update(() => {
|
||||
const link = $createLinkedImageNodeFromImageData(image);
|
||||
$insertNodes([link]);
|
||||
link.select();
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
@@ -192,11 +192,17 @@ export function $showMediaForm(media: MediaNode|null, context: EditorUiContext):
|
||||
let formDefaults = {};
|
||||
if (media) {
|
||||
const nodeAttrs = media.getAttributes();
|
||||
const nodeDOM = media.exportDOM(context.editor).element;
|
||||
const nodeHtml = (nodeDOM instanceof HTMLElement) ? nodeDOM.outerHTML : '';
|
||||
|
||||
formDefaults = {
|
||||
src: nodeAttrs.src || nodeAttrs.data || '',
|
||||
src: nodeAttrs.src || nodeAttrs.data || media.getSources()[0]?.src || '',
|
||||
width: nodeAttrs.width,
|
||||
height: nodeAttrs.height,
|
||||
embed: '',
|
||||
embed: nodeHtml,
|
||||
|
||||
// This is used so we can check for edits against the embed field on submit
|
||||
embed_check: nodeHtml,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +220,8 @@ export const media: EditorFormDefinition = {
|
||||
}));
|
||||
|
||||
const embedCode = (formData.get('embed') || '').toString().trim();
|
||||
if (embedCode) {
|
||||
const embedCheck = (formData.get('embed_check') || '').toString().trim();
|
||||
if (embedCode && embedCode !== embedCheck) {
|
||||
context.editor.update(() => {
|
||||
const node = $createMediaNodeFromHtml(embedCode);
|
||||
if (selectedNode && node) {
|
||||
@@ -236,6 +243,7 @@ export const media: EditorFormDefinition = {
|
||||
if (selectedNode) {
|
||||
selectedNode.setSrc(src);
|
||||
selectedNode.setWidthAndHeight(width, height);
|
||||
context.manager.triggerFutureStateRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -281,6 +289,11 @@ export const media: EditorFormDefinition = {
|
||||
name: 'embed',
|
||||
type: 'textarea',
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
name: 'embed_check',
|
||||
type: 'hidden',
|
||||
},
|
||||
],
|
||||
}
|
||||
])
|
||||
|
||||
@@ -224,6 +224,10 @@ export function getImageToolbarContent(): EditorUiElement[] {
|
||||
return [new EditorButton(image)];
|
||||
}
|
||||
|
||||
export function getMediaToolbarContent(): EditorUiElement[] {
|
||||
return [new EditorButton(media)];
|
||||
}
|
||||
|
||||
export function getLinkToolbarContent(): EditorUiElement[] {
|
||||
return [
|
||||
new EditorButton(link),
|
||||
|
||||
@@ -11,7 +11,7 @@ import {el} from "../../utils/dom";
|
||||
export interface EditorFormFieldDefinition {
|
||||
label: string;
|
||||
name: string;
|
||||
type: 'text' | 'select' | 'textarea' | 'checkbox';
|
||||
type: 'text' | 'select' | 'textarea' | 'checkbox' | 'hidden';
|
||||
}
|
||||
|
||||
export interface EditorSelectFormFieldDefinition extends EditorFormFieldDefinition {
|
||||
@@ -67,6 +67,9 @@ export class EditorFormField extends EditorUiElement {
|
||||
input = el('textarea', {id, name: this.definition.name, class: 'editor-form-field-input'});
|
||||
} else if (this.definition.type === 'checkbox') {
|
||||
input = el('input', {id, name: this.definition.name, type: 'checkbox', class: 'editor-form-field-input-checkbox', value: 'true'});
|
||||
} else if (this.definition.type === 'hidden') {
|
||||
input = el('input', {id, name: this.definition.name, type: 'hidden'});
|
||||
return el('div', {hidden: 'true'}, [input]);
|
||||
} else {
|
||||
input = el('input', {id, name: this.definition.name, class: 'editor-form-field-input'});
|
||||
}
|
||||
|
||||
@@ -12,17 +12,21 @@ function isNodeWithSize(node: LexicalNode): node is NodeHasSize&LexicalNode {
|
||||
|
||||
class NodeResizer {
|
||||
protected context: EditorUiContext;
|
||||
protected dom: HTMLElement|null = null;
|
||||
protected resizerDOM: HTMLElement|null = null;
|
||||
protected targetNode: LexicalNode|null = null;
|
||||
protected scrollContainer: HTMLElement;
|
||||
|
||||
protected mouseTracker: MouseDragTracker|null = null;
|
||||
protected activeSelection: string = '';
|
||||
protected loadAbortController = new AbortController();
|
||||
|
||||
constructor(context: EditorUiContext) {
|
||||
this.context = context;
|
||||
this.scrollContainer = context.scrollDOM;
|
||||
|
||||
this.onSelectionChange = this.onSelectionChange.bind(this);
|
||||
this.onTargetDOMLoad = this.onTargetDOMLoad.bind(this);
|
||||
|
||||
context.manager.onSelectionChange(this.onSelectionChange);
|
||||
}
|
||||
|
||||
@@ -34,12 +38,7 @@ class NodeResizer {
|
||||
|
||||
if (nodes.length === 1 && isNodeWithSize(nodes[0])) {
|
||||
const node = nodes[0];
|
||||
const nodeKey = node.getKey();
|
||||
let nodeDOM = this.context.editor.getElementByKey(nodeKey);
|
||||
|
||||
if (nodeDOM && nodeDOM.nodeName === 'SPAN') {
|
||||
nodeDOM = nodeDOM.firstElementChild as HTMLElement;
|
||||
}
|
||||
let nodeDOM = this.getTargetDOM(node)
|
||||
|
||||
if (nodeDOM) {
|
||||
this.showForNode(node, nodeDOM);
|
||||
@@ -47,56 +46,81 @@ class NodeResizer {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
teardown() {
|
||||
this.context.manager.offSelectionChange(this.onSelectionChange);
|
||||
this.hide();
|
||||
}
|
||||
|
||||
protected showForNode(node: NodeHasSize&LexicalNode, dom: HTMLElement) {
|
||||
this.dom = this.buildDOM();
|
||||
protected showForNode(node: NodeHasSize&LexicalNode, targetDOM: HTMLElement) {
|
||||
this.resizerDOM = this.buildDOM();
|
||||
this.targetNode = node;
|
||||
|
||||
let ghost = el('span', {class: 'editor-node-resizer-ghost'});
|
||||
if ($isImageNode(node)) {
|
||||
ghost = el('img', {src: dom.getAttribute('src'), class: 'editor-node-resizer-ghost'});
|
||||
ghost = el('img', {src: targetDOM.getAttribute('src'), class: 'editor-node-resizer-ghost'});
|
||||
}
|
||||
this.dom.append(ghost);
|
||||
this.resizerDOM.append(ghost);
|
||||
|
||||
this.context.scrollDOM.append(this.dom);
|
||||
this.updateDOMPosition(dom);
|
||||
this.context.scrollDOM.append(this.resizerDOM);
|
||||
this.updateResizerPosition();
|
||||
|
||||
this.mouseTracker = this.setupTracker(this.dom, node, dom);
|
||||
this.mouseTracker = this.setupTracker(this.resizerDOM, node, targetDOM);
|
||||
this.activeSelection = node.getKey();
|
||||
|
||||
if (targetDOM.matches('img, embed, iframe, object')) {
|
||||
this.loadAbortController = new AbortController();
|
||||
targetDOM.addEventListener('load', this.onTargetDOMLoad, { signal: this.loadAbortController.signal });
|
||||
}
|
||||
}
|
||||
|
||||
protected updateDOMPosition(nodeDOM: HTMLElement) {
|
||||
if (!this.dom) {
|
||||
protected updateResizerPosition() {
|
||||
const targetDOM = this.getTargetDOM(this.targetNode);
|
||||
if (!this.resizerDOM || !targetDOM) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollAreaRect = this.scrollContainer.getBoundingClientRect();
|
||||
const nodeRect = nodeDOM.getBoundingClientRect();
|
||||
const nodeRect = targetDOM.getBoundingClientRect();
|
||||
const top = nodeRect.top - (scrollAreaRect.top - this.scrollContainer.scrollTop);
|
||||
const left = nodeRect.left - scrollAreaRect.left;
|
||||
|
||||
this.dom.style.top = `${top}px`;
|
||||
this.dom.style.left = `${left}px`;
|
||||
this.dom.style.width = nodeRect.width + 'px';
|
||||
this.dom.style.height = nodeRect.height + 'px';
|
||||
this.resizerDOM.style.top = `${top}px`;
|
||||
this.resizerDOM.style.left = `${left}px`;
|
||||
this.resizerDOM.style.width = nodeRect.width + 'px';
|
||||
this.resizerDOM.style.height = nodeRect.height + 'px';
|
||||
}
|
||||
|
||||
protected updateDOMSize(width: number, height: number): void {
|
||||
if (!this.dom) {
|
||||
if (!this.resizerDOM) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dom.style.width = width + 'px';
|
||||
this.dom.style.height = height + 'px';
|
||||
this.resizerDOM.style.width = width + 'px';
|
||||
this.resizerDOM.style.height = height + 'px';
|
||||
}
|
||||
|
||||
protected hide() {
|
||||
this.mouseTracker?.teardown();
|
||||
this.dom?.remove();
|
||||
this.resizerDOM?.remove();
|
||||
this.targetNode = null;
|
||||
this.activeSelection = '';
|
||||
this.loadAbortController.abort();
|
||||
}
|
||||
|
||||
protected buildDOM() {
|
||||
@@ -110,7 +134,7 @@ class NodeResizer {
|
||||
}, handleElems);
|
||||
}
|
||||
|
||||
setupTracker(container: HTMLElement, node: NodeHasSize, nodeDOM: HTMLElement): MouseDragTracker {
|
||||
setupTracker(container: HTMLElement, node: NodeHasSize&LexicalNode, nodeDOM: HTMLElement): MouseDragTracker {
|
||||
let startingWidth: number = 0;
|
||||
let startingHeight: number = 0;
|
||||
let startingRatio: number = 0;
|
||||
@@ -140,7 +164,7 @@ class NodeResizer {
|
||||
|
||||
return new MouseDragTracker(container, '.editor-node-resizer-handle', {
|
||||
down(event: MouseEvent, handle: HTMLElement) {
|
||||
_this.dom?.classList.add('active');
|
||||
_this.resizerDOM?.classList.add('active');
|
||||
_this.context.editor.getEditorState().read(() => {
|
||||
const domRect = nodeDOM.getBoundingClientRect();
|
||||
startingWidth = node.getWidth() || domRect.width;
|
||||
@@ -163,12 +187,15 @@ class NodeResizer {
|
||||
_this.context.editor.update(() => {
|
||||
node.setWidth(size.width);
|
||||
node.setHeight(hasHeight ? size.height : 0);
|
||||
_this.context.manager.triggerLayoutUpdate();
|
||||
requestAnimationFrame(() => {
|
||||
_this.updateDOMPosition(nodeDOM);
|
||||
})
|
||||
}, {
|
||||
onUpdate: () => {
|
||||
requestAnimationFrame(() => {
|
||||
_this.context.manager.triggerLayoutUpdate();
|
||||
_this.updateResizerPosition();
|
||||
});
|
||||
}
|
||||
});
|
||||
_this.dom?.classList.remove('active');
|
||||
_this.resizerDOM?.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class TableResizer {
|
||||
protected targetCell: HTMLElement|null = null;
|
||||
protected xMarkerAtStart : boolean = false;
|
||||
protected yMarkerAtStart : boolean = false;
|
||||
protected activeInTable: boolean = false;
|
||||
|
||||
constructor(editor: LexicalEditor, editScrollContainer: HTMLElement) {
|
||||
this.editor = editor;
|
||||
@@ -33,9 +34,10 @@ class TableResizer {
|
||||
}
|
||||
|
||||
protected setupListeners() {
|
||||
this.onTableMouseOver = this.onTableMouseOver.bind(this);
|
||||
this.onCellMouseMove = this.onCellMouseMove.bind(this);
|
||||
this.onScrollOrResize = this.onScrollOrResize.bind(this);
|
||||
this.editScrollContainer.addEventListener('mousemove', this.onCellMouseMove);
|
||||
this.editScrollContainer.addEventListener('mouseover', this.onTableMouseOver, { passive: true });
|
||||
window.addEventListener('scroll', this.onScrollOrResize, {capture: true, passive: true});
|
||||
window.addEventListener('resize', this.onScrollOrResize, {passive: true});
|
||||
}
|
||||
@@ -44,8 +46,26 @@ class TableResizer {
|
||||
this.updateCurrentMarkerTargetPosition();
|
||||
}
|
||||
|
||||
protected onTableMouseOver(event: MouseEvent): void {
|
||||
if (this.dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const table = (event.target as HTMLElement).closest('table') as HTMLElement|null;
|
||||
|
||||
if (table && !this.activeInTable) {
|
||||
this.editScrollContainer.addEventListener('mousemove', this.onCellMouseMove, { passive: true });
|
||||
this.onCellMouseMove(event);
|
||||
this.activeInTable = true;
|
||||
} else if (!table && this.activeInTable) {
|
||||
this.editScrollContainer.removeEventListener('mousemove', this.onCellMouseMove);
|
||||
this.hideMarkers();
|
||||
this.activeInTable = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected onCellMouseMove(event: MouseEvent) {
|
||||
const cell = (event.target as HTMLElement).closest('td,th') as HTMLElement;
|
||||
const cell = (event.target as HTMLElement).closest('td,th') as HTMLElement|null;
|
||||
if (!cell || this.dragging) {
|
||||
return;
|
||||
}
|
||||
@@ -66,10 +86,16 @@ class TableResizer {
|
||||
protected updateMarkersTo(cell: HTMLElement, xPos: number, yPos: number) {
|
||||
const markers: MarkerDomRecord = this.getMarkers();
|
||||
const table = cell.closest('table') as HTMLElement;
|
||||
const caption: HTMLTableCaptionElement|null = table.querySelector('caption');
|
||||
const tableRect = table.getBoundingClientRect();
|
||||
const editBounds = this.editScrollContainer.getBoundingClientRect();
|
||||
|
||||
const maxTop = Math.max(tableRect.top, editBounds.top);
|
||||
let tableTop = tableRect.top;
|
||||
if (caption) {
|
||||
tableTop = caption.getBoundingClientRect().bottom;
|
||||
}
|
||||
|
||||
const maxTop = Math.max(tableTop, editBounds.top);
|
||||
const maxBottom = Math.min(tableRect.bottom, editBounds.bottom);
|
||||
const maxHeight = maxBottom - maxTop;
|
||||
markers.x.style.left = xPos + 'px';
|
||||
@@ -85,6 +111,13 @@ class TableResizer {
|
||||
markers.x.hidden = tableRect.top > editBounds.bottom || tableRect.bottom < editBounds.top;
|
||||
}
|
||||
|
||||
protected hideMarkers(): void {
|
||||
if (this.markerDom) {
|
||||
this.markerDom.x.hidden = true;
|
||||
this.markerDom.y.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected updateCurrentMarkerTargetPosition(): void {
|
||||
if (!this.targetCell) {
|
||||
return;
|
||||
|
||||
@@ -34,7 +34,11 @@ export class EditorContextToolbar extends EditorContainerUiElement {
|
||||
|
||||
dom.hidden = !showing;
|
||||
|
||||
if (!showing) {
|
||||
if (!this.target.isConnected) {
|
||||
// If our target is no longer in the DOM, tell the manager an update is needed.
|
||||
this.getContext().manager.triggerFutureStateRefresh();
|
||||
return;
|
||||
} else if (!showing) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
getCodeToolbarContent, getDetailsToolbarContent,
|
||||
getImageToolbarContent,
|
||||
getLinkToolbarContent,
|
||||
getMainEditorFullToolbar, getTableToolbarContent
|
||||
getMainEditorFullToolbar, getMediaToolbarContent, getTableToolbarContent
|
||||
} from "./defaults/toolbars";
|
||||
import {EditorUIManager} from "./framework/manager";
|
||||
import {EditorUiContext} from "./framework/core";
|
||||
@@ -44,6 +44,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro
|
||||
selector: 'img:not([drawio-diagram] img)',
|
||||
content: getImageToolbarContent(),
|
||||
});
|
||||
manager.registerContextToolbar('media', {
|
||||
selector: '.editor-media-wrap',
|
||||
content: getMediaToolbarContent(),
|
||||
});
|
||||
manager.registerContextToolbar('link', {
|
||||
selector: 'a',
|
||||
content: getLinkToolbarContent(),
|
||||
|
||||
@@ -52,12 +52,19 @@ export type StyleMap = Map<string, string>;
|
||||
/**
|
||||
* Creates a map from an element's styles.
|
||||
* 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.
|
||||
*/
|
||||
export function extractStyleMapFromElement(element: HTMLElement): StyleMap {
|
||||
const map: StyleMap = new Map();
|
||||
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(';');
|
||||
for (const rule of rules) {
|
||||
@@ -72,6 +79,17 @@ export function extractStyleMapFromElement(element: HTMLElement): StyleMap {
|
||||
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) {
|
||||
if (value) {
|
||||
element.setAttribute(name, value);
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
$createParagraphNode, $createRangeSelection,
|
||||
$getRoot,
|
||||
$getSelection, $isBlockElementNode, $isDecoratorNode,
|
||||
$isElementNode,
|
||||
$isElementNode, $isParagraphNode,
|
||||
$isTextNode,
|
||||
$setSelection,
|
||||
BaseSelection, DecoratorNode,
|
||||
@@ -60,12 +60,19 @@ export function $selectionContainsTextFormat(selection: BaseSelection | null, fo
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const node of selection.getNodes()) {
|
||||
// Check text nodes
|
||||
const nodes = selection.getNodes();
|
||||
for (const node of nodes) {
|
||||
if ($isTextNode(node) && node.hasFormat(format)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're in an empty paragraph, check the paragraph format
|
||||
if (nodes.length === 1 && $isParagraphNode(nodes[0]) && nodes[0].hasTextFormat(format)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -454,7 +454,7 @@ body.editor-is-fullscreen {
|
||||
.editor-media-wrap {
|
||||
display: inline-block;
|
||||
cursor: not-allowed;
|
||||
iframe {
|
||||
iframe, video {
|
||||
pointer-events: none;
|
||||
}
|
||||
&.align-left {
|
||||
|
||||
Reference in New Issue
Block a user