mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Lexical: Added some level of img/media alignment
This commit is contained in:
parent
1ebb0f8c93
commit
1c9afcb84e
@ -10,6 +10,7 @@ import {
|
|||||||
import type {EditorConfig} from "lexical/LexicalEditor";
|
import type {EditorConfig} from "lexical/LexicalEditor";
|
||||||
import {EditorDecoratorAdapter} from "../ui/framework/decorator";
|
import {EditorDecoratorAdapter} from "../ui/framework/decorator";
|
||||||
import {el} from "../utils/dom";
|
import {el} from "../utils/dom";
|
||||||
|
import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common";
|
||||||
|
|
||||||
export interface ImageNodeOptions {
|
export interface ImageNodeOptions {
|
||||||
alt?: string;
|
alt?: string;
|
||||||
@ -22,6 +23,7 @@ export type SerializedImageNode = Spread<{
|
|||||||
alt: string;
|
alt: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
alignment: CommonBlockAlignment;
|
||||||
}, SerializedLexicalNode>
|
}, SerializedLexicalNode>
|
||||||
|
|
||||||
export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
||||||
@ -29,7 +31,7 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
|||||||
__alt: string = '';
|
__alt: string = '';
|
||||||
__width: number = 0;
|
__width: number = 0;
|
||||||
__height: number = 0;
|
__height: number = 0;
|
||||||
// TODO - Alignment
|
__alignment: CommonBlockAlignment = '';
|
||||||
|
|
||||||
static getType(): string {
|
static getType(): string {
|
||||||
return 'image';
|
return 'image';
|
||||||
@ -97,6 +99,16 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
|||||||
return self.__width;
|
return self.__width;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAlignment(alignment: CommonBlockAlignment) {
|
||||||
|
const self = this.getWritable();
|
||||||
|
self.__alignment = alignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAlignment(): CommonBlockAlignment {
|
||||||
|
const self = this.getLatest();
|
||||||
|
return self.__alignment;
|
||||||
|
}
|
||||||
|
|
||||||
isInline(): boolean {
|
isInline(): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -121,6 +133,11 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
|||||||
if (this.__alt) {
|
if (this.__alt) {
|
||||||
element.setAttribute('alt', this.__alt);
|
element.setAttribute('alt', this.__alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.__alignment) {
|
||||||
|
element.classList.add('align-' + this.__alignment);
|
||||||
|
}
|
||||||
|
|
||||||
return el('span', {class: 'editor-image-wrap'}, [
|
return el('span', {class: 'editor-image-wrap'}, [
|
||||||
element,
|
element,
|
||||||
]);
|
]);
|
||||||
@ -158,6 +175,15 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prevNode.__alignment !== this.__alignment) {
|
||||||
|
if (prevNode.__alignment) {
|
||||||
|
image.classList.remove('align-' + prevNode.__alignment);
|
||||||
|
}
|
||||||
|
if (this.__alignment) {
|
||||||
|
image.classList.add('align-' + this.__alignment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,9 +200,10 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
|||||||
width: Number.parseInt(element.getAttribute('width') || '0'),
|
width: Number.parseInt(element.getAttribute('width') || '0'),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const node = new ImageNode(src, options);
|
||||||
node: new ImageNode(src, options),
|
node.setAlignment(extractAlignmentFromElement(element));
|
||||||
};
|
|
||||||
|
return { node };
|
||||||
},
|
},
|
||||||
priority: 3,
|
priority: 3,
|
||||||
};
|
};
|
||||||
@ -191,16 +218,19 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
|||||||
src: this.__src,
|
src: this.__src,
|
||||||
alt: this.__alt,
|
alt: this.__alt,
|
||||||
height: this.__height,
|
height: this.__height,
|
||||||
width: this.__width
|
width: this.__width,
|
||||||
|
alignment: this.__alignment,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static importJSON(serializedNode: SerializedImageNode): ImageNode {
|
static importJSON(serializedNode: SerializedImageNode): ImageNode {
|
||||||
return $createImageNode(serializedNode.src, {
|
const node = $createImageNode(serializedNode.src, {
|
||||||
alt: serializedNode.alt,
|
alt: serializedNode.alt,
|
||||||
width: serializedNode.width,
|
width: serializedNode.width,
|
||||||
height: serializedNode.height,
|
height: serializedNode.height,
|
||||||
});
|
});
|
||||||
|
node.setAlignment(serializedNode.alignment);
|
||||||
|
return node;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,6 +9,13 @@ import {
|
|||||||
import type {EditorConfig} from "lexical/LexicalEditor";
|
import type {EditorConfig} from "lexical/LexicalEditor";
|
||||||
|
|
||||||
import {el} from "../utils/dom";
|
import {el} from "../utils/dom";
|
||||||
|
import {
|
||||||
|
CommonBlockAlignment,
|
||||||
|
SerializedCommonBlockNode,
|
||||||
|
setCommonBlockPropsFromElement,
|
||||||
|
updateElementWithCommonBlockProps
|
||||||
|
} from "./_common";
|
||||||
|
import {elem} from "../../services/dom";
|
||||||
|
|
||||||
export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
|
export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
|
||||||
export type MediaNodeSource = {
|
export type MediaNodeSource = {
|
||||||
@ -20,10 +27,10 @@ export type SerializedMediaNode = Spread<{
|
|||||||
tag: MediaNodeTag;
|
tag: MediaNodeTag;
|
||||||
attributes: Record<string, string>;
|
attributes: Record<string, string>;
|
||||||
sources: MediaNodeSource[];
|
sources: MediaNodeSource[];
|
||||||
}, SerializedElementNode>
|
}, SerializedCommonBlockNode>
|
||||||
|
|
||||||
const attributeAllowList = [
|
const attributeAllowList = [
|
||||||
'id', 'width', 'height', 'style', 'title', 'name',
|
'width', 'height', 'style', 'title', 'name',
|
||||||
'src', 'allow', 'allowfullscreen', 'loading', 'sandbox',
|
'src', 'allow', 'allowfullscreen', 'loading', 'sandbox',
|
||||||
'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop',
|
'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop',
|
||||||
'muted', 'playsinline', 'poster', 'preload'
|
'muted', 'playsinline', 'poster', 'preload'
|
||||||
@ -39,7 +46,7 @@ function filterAttributes(attributes: Record<string, string>): Record<string, st
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode {
|
function domElementToNode(tag: MediaNodeTag, element: HTMLElement): MediaNode {
|
||||||
const node = $createMediaNode(tag);
|
const node = $createMediaNode(tag);
|
||||||
|
|
||||||
const attributes: Record<string, string> = {};
|
const attributes: Record<string, string> = {};
|
||||||
@ -62,10 +69,14 @@ function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode {
|
|||||||
node.setSources(sources);
|
node.setSources(sources);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCommonBlockPropsFromElement(element, node);
|
||||||
|
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MediaNode extends ElementNode {
|
export class MediaNode extends ElementNode {
|
||||||
|
__id: string = '';
|
||||||
|
__alignment: CommonBlockAlignment = '';
|
||||||
__tag: MediaNodeTag;
|
__tag: MediaNodeTag;
|
||||||
__attributes: Record<string, string> = {};
|
__attributes: Record<string, string> = {};
|
||||||
__sources: MediaNodeSource[] = [];
|
__sources: MediaNodeSource[] = [];
|
||||||
@ -135,11 +146,32 @@ export class MediaNode extends ElementNode {
|
|||||||
this.setAttributes(attrs);
|
this.setAttributes(attrs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setId(id: string) {
|
||||||
|
const self = this.getWritable();
|
||||||
|
self.__id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): string {
|
||||||
|
const self = this.getLatest();
|
||||||
|
return self.__id;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlignment(alignment: CommonBlockAlignment) {
|
||||||
|
const self = this.getWritable();
|
||||||
|
self.__alignment = alignment;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAlignment(): CommonBlockAlignment {
|
||||||
|
const self = this.getLatest();
|
||||||
|
return self.__alignment;
|
||||||
|
}
|
||||||
|
|
||||||
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
|
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
|
||||||
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);
|
||||||
return el(this.__tag, this.__attributes, sourceEls);
|
updateElementWithCommonBlockProps(element, this);
|
||||||
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDOM(prevNode: unknown, dom: HTMLElement) {
|
updateDOM(prevNode: unknown, dom: HTMLElement) {
|
||||||
@ -175,6 +207,8 @@ export class MediaNode extends ElementNode {
|
|||||||
...super.exportJSON(),
|
...super.exportJSON(),
|
||||||
type: 'media',
|
type: 'media',
|
||||||
version: 1,
|
version: 1,
|
||||||
|
id: this.__id,
|
||||||
|
alignment: this.__alignment,
|
||||||
tag: this.__tag,
|
tag: this.__tag,
|
||||||
attributes: this.__attributes,
|
attributes: this.__attributes,
|
||||||
sources: this.__sources,
|
sources: this.__sources,
|
||||||
@ -182,7 +216,10 @@ export class MediaNode extends ElementNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static importJSON(serializedNode: SerializedMediaNode): MediaNode {
|
static importJSON(serializedNode: SerializedMediaNode): MediaNode {
|
||||||
return $createMediaNode(serializedNode.tag);
|
const node = $createMediaNode(serializedNode.tag);
|
||||||
|
node.setId(serializedNode.id);
|
||||||
|
node.setAlignment(serializedNode.alignment);
|
||||||
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -196,7 +233,7 @@ export function $createMediaNodeFromHtml(html: string): MediaNode | null {
|
|||||||
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
|
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
|
||||||
|
|
||||||
const el = doc.body.children[0];
|
const el = doc.body.children[0];
|
||||||
if (!el) {
|
if (!(el instanceof HTMLElement)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,18 +6,19 @@
|
|||||||
|
|
||||||
## Main Todo
|
## Main Todo
|
||||||
|
|
||||||
- Alignments: Handle inline block content (image, video)
|
|
||||||
- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
|
- 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)
|
- Media resize support (like images)
|
||||||
- Table caption text support
|
|
||||||
- Mac: Shortcut support via command.
|
- Mac: Shortcut support via command.
|
||||||
|
|
||||||
## Secondary Todo
|
## Secondary Todo
|
||||||
|
|
||||||
- Color picker support in table form color fields
|
- Color picker support in table form color fields
|
||||||
|
- Table caption text support
|
||||||
|
|
||||||
## Bugs
|
## 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.
|
- 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
|
- 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.
|
||||||
|
@ -1,17 +1,32 @@
|
|||||||
import {$getSelection, BaseSelection} from "lexical";
|
import {BaseSelection, LexicalEditor} from "lexical";
|
||||||
import {EditorButtonDefinition} from "../../framework/buttons";
|
import {EditorButtonDefinition} from "../../framework/buttons";
|
||||||
import alignLeftIcon from "@icons/editor/align-left.svg";
|
import alignLeftIcon from "@icons/editor/align-left.svg";
|
||||||
import {EditorUiContext} from "../../framework/core";
|
import {EditorUiContext} from "../../framework/core";
|
||||||
import alignCenterIcon from "@icons/editor/align-center.svg";
|
import alignCenterIcon from "@icons/editor/align-center.svg";
|
||||||
import alignRightIcon from "@icons/editor/align-right.svg";
|
import alignRightIcon from "@icons/editor/align-right.svg";
|
||||||
import alignJustifyIcon from "@icons/editor/align-justify.svg";
|
import alignJustifyIcon from "@icons/editor/align-justify.svg";
|
||||||
import {$getBlockElementNodesInSelection, $selectionContainsElementFormat} from "../../../utils/selection";
|
import {
|
||||||
|
$getBlockElementNodesInSelection,
|
||||||
|
$getDecoratorNodesInSelection,
|
||||||
|
$selectionContainsAlignment, getLastSelection
|
||||||
|
} from "../../../utils/selection";
|
||||||
import {CommonBlockAlignment} from "../../../nodes/_common";
|
import {CommonBlockAlignment} from "../../../nodes/_common";
|
||||||
import {nodeHasAlignment} from "../../../utils/nodes";
|
import {nodeHasAlignment} from "../../../utils/nodes";
|
||||||
|
|
||||||
|
|
||||||
function setAlignmentForSection(alignment: CommonBlockAlignment): void {
|
function setAlignmentForSection(editor: LexicalEditor, alignment: CommonBlockAlignment): void {
|
||||||
const selection = $getSelection();
|
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!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle normal block/range alignment
|
||||||
const elements = $getBlockElementNodesInSelection(selection);
|
const elements = $getBlockElementNodesInSelection(selection);
|
||||||
for (const node of elements) {
|
for (const node of elements) {
|
||||||
if (nodeHasAlignment(node)) {
|
if (nodeHasAlignment(node)) {
|
||||||
@ -24,10 +39,10 @@ export const alignLeft: EditorButtonDefinition = {
|
|||||||
label: 'Align left',
|
label: 'Align left',
|
||||||
icon: alignLeftIcon,
|
icon: alignLeftIcon,
|
||||||
action(context: EditorUiContext) {
|
action(context: EditorUiContext) {
|
||||||
context.editor.update(() => setAlignmentForSection('left'));
|
context.editor.update(() => setAlignmentForSection(context.editor, 'left'));
|
||||||
},
|
},
|
||||||
isActive(selection: BaseSelection|null) {
|
isActive(selection: BaseSelection|null) {
|
||||||
return $selectionContainsElementFormat(selection, 'left');
|
return $selectionContainsAlignment(selection, 'left');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -35,10 +50,10 @@ export const alignCenter: EditorButtonDefinition = {
|
|||||||
label: 'Align center',
|
label: 'Align center',
|
||||||
icon: alignCenterIcon,
|
icon: alignCenterIcon,
|
||||||
action(context: EditorUiContext) {
|
action(context: EditorUiContext) {
|
||||||
context.editor.update(() => setAlignmentForSection('center'));
|
context.editor.update(() => setAlignmentForSection(context.editor, 'center'));
|
||||||
},
|
},
|
||||||
isActive(selection: BaseSelection|null) {
|
isActive(selection: BaseSelection|null) {
|
||||||
return $selectionContainsElementFormat(selection, 'center');
|
return $selectionContainsAlignment(selection, 'center');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -46,10 +61,10 @@ export const alignRight: EditorButtonDefinition = {
|
|||||||
label: 'Align right',
|
label: 'Align right',
|
||||||
icon: alignRightIcon,
|
icon: alignRightIcon,
|
||||||
action(context: EditorUiContext) {
|
action(context: EditorUiContext) {
|
||||||
context.editor.update(() => setAlignmentForSection('right'));
|
context.editor.update(() => setAlignmentForSection(context.editor, 'right'));
|
||||||
},
|
},
|
||||||
isActive(selection: BaseSelection|null) {
|
isActive(selection: BaseSelection|null) {
|
||||||
return $selectionContainsElementFormat(selection, 'right');
|
return $selectionContainsAlignment(selection, 'right');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,9 +72,9 @@ export const alignJustify: EditorButtonDefinition = {
|
|||||||
label: 'Align justify',
|
label: 'Align justify',
|
||||||
icon: alignJustifyIcon,
|
icon: alignJustifyIcon,
|
||||||
action(context: EditorUiContext) {
|
action(context: EditorUiContext) {
|
||||||
context.editor.update(() => setAlignmentForSection('justify'));
|
context.editor.update(() => setAlignmentForSection(context.editor, 'justify'));
|
||||||
},
|
},
|
||||||
isActive(selection: BaseSelection|null) {
|
isActive(selection: BaseSelection|null) {
|
||||||
return $selectionContainsElementFormat(selection, 'justify');
|
return $selectionContainsAlignment(selection, 'justify');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -2,11 +2,11 @@ import {
|
|||||||
$createNodeSelection,
|
$createNodeSelection,
|
||||||
$createParagraphNode,
|
$createParagraphNode,
|
||||||
$getRoot,
|
$getRoot,
|
||||||
$getSelection,
|
$getSelection, $isDecoratorNode,
|
||||||
$isElementNode,
|
$isElementNode,
|
||||||
$isTextNode,
|
$isTextNode,
|
||||||
$setSelection,
|
$setSelection,
|
||||||
BaseSelection,
|
BaseSelection, DecoratorNode,
|
||||||
ElementFormatType,
|
ElementFormatType,
|
||||||
ElementNode, LexicalEditor,
|
ElementNode, LexicalEditor,
|
||||||
LexicalNode,
|
LexicalNode,
|
||||||
@ -16,8 +16,9 @@ import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexi
|
|||||||
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes";
|
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes";
|
||||||
import {$setBlocksType} from "@lexical/selection";
|
import {$setBlocksType} from "@lexical/selection";
|
||||||
|
|
||||||
import {$getParentOfType} from "./nodes";
|
import {$getParentOfType, nodeHasAlignment} from "./nodes";
|
||||||
import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
|
import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
|
||||||
|
import {CommonBlockAlignment} from "../nodes/_common";
|
||||||
|
|
||||||
const lastSelectionByEditor = new WeakMap<LexicalEditor, BaseSelection|null>;
|
const lastSelectionByEditor = new WeakMap<LexicalEditor, BaseSelection|null>;
|
||||||
|
|
||||||
@ -120,10 +121,10 @@ export function $selectionContainsNode(selection: BaseSelection | null, node: Le
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function $selectionContainsElementFormat(selection: BaseSelection | null, format: ElementFormatType): boolean {
|
export function $selectionContainsAlignment(selection: BaseSelection | null, alignment: CommonBlockAlignment): boolean {
|
||||||
const nodes = $getBlockElementNodesInSelection(selection);
|
const nodes = $getBlockElementNodesInSelection(selection);
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.getFormatType() === format) {
|
if (nodeHasAlignment(node) && node.getAlignment() === alignment) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -148,4 +149,12 @@ export function $getBlockElementNodesInSelection(selection: BaseSelection | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(blockNodes.values());
|
return Array.from(blockNodes.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $getDecoratorNodesInSelection(selection: BaseSelection | null): DecoratorNode<any>[] {
|
||||||
|
if (!selection) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return selection.getNodes().filter(node => $isDecoratorNode(node));
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user