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 {EditorDecoratorAdapter} from "../ui/framework/decorator";
|
||||
import {el} from "../utils/dom";
|
||||
import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common";
|
||||
|
||||
export interface ImageNodeOptions {
|
||||
alt?: string;
|
||||
@ -22,6 +23,7 @@ export type SerializedImageNode = Spread<{
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
alignment: CommonBlockAlignment;
|
||||
}, SerializedLexicalNode>
|
||||
|
||||
export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
||||
@ -29,7 +31,7 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
||||
__alt: string = '';
|
||||
__width: number = 0;
|
||||
__height: number = 0;
|
||||
// TODO - Alignment
|
||||
__alignment: CommonBlockAlignment = '';
|
||||
|
||||
static getType(): string {
|
||||
return 'image';
|
||||
@ -97,6 +99,16 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
||||
return self.__width;
|
||||
}
|
||||
|
||||
setAlignment(alignment: CommonBlockAlignment) {
|
||||
const self = this.getWritable();
|
||||
self.__alignment = alignment;
|
||||
}
|
||||
|
||||
getAlignment(): CommonBlockAlignment {
|
||||
const self = this.getLatest();
|
||||
return self.__alignment;
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true;
|
||||
}
|
||||
@ -121,6 +133,11 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
||||
if (this.__alt) {
|
||||
element.setAttribute('alt', this.__alt);
|
||||
}
|
||||
|
||||
if (this.__alignment) {
|
||||
element.classList.add('align-' + this.__alignment);
|
||||
}
|
||||
|
||||
return el('span', {class: 'editor-image-wrap'}, [
|
||||
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;
|
||||
}
|
||||
|
||||
@ -174,9 +200,10 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
||||
width: Number.parseInt(element.getAttribute('width') || '0'),
|
||||
}
|
||||
|
||||
return {
|
||||
node: new ImageNode(src, options),
|
||||
};
|
||||
const node = new ImageNode(src, options);
|
||||
node.setAlignment(extractAlignmentFromElement(element));
|
||||
|
||||
return { node };
|
||||
},
|
||||
priority: 3,
|
||||
};
|
||||
@ -191,16 +218,19 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
|
||||
src: this.__src,
|
||||
alt: this.__alt,
|
||||
height: this.__height,
|
||||
width: this.__width
|
||||
width: this.__width,
|
||||
alignment: this.__alignment,
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedImageNode): ImageNode {
|
||||
return $createImageNode(serializedNode.src, {
|
||||
const node = $createImageNode(serializedNode.src, {
|
||||
alt: serializedNode.alt,
|
||||
width: serializedNode.width,
|
||||
height: serializedNode.height,
|
||||
});
|
||||
node.setAlignment(serializedNode.alignment);
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,13 @@ import {
|
||||
import type {EditorConfig} from "lexical/LexicalEditor";
|
||||
|
||||
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 MediaNodeSource = {
|
||||
@ -20,10 +27,10 @@ export type SerializedMediaNode = Spread<{
|
||||
tag: MediaNodeTag;
|
||||
attributes: Record<string, string>;
|
||||
sources: MediaNodeSource[];
|
||||
}, SerializedElementNode>
|
||||
}, SerializedCommonBlockNode>
|
||||
|
||||
const attributeAllowList = [
|
||||
'id', 'width', 'height', 'style', 'title', 'name',
|
||||
'width', 'height', 'style', 'title', 'name',
|
||||
'src', 'allow', 'allowfullscreen', 'loading', 'sandbox',
|
||||
'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop',
|
||||
'muted', 'playsinline', 'poster', 'preload'
|
||||
@ -39,7 +46,7 @@ function filterAttributes(attributes: Record<string, string>): Record<string, st
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode {
|
||||
function domElementToNode(tag: MediaNodeTag, element: HTMLElement): MediaNode {
|
||||
const node = $createMediaNode(tag);
|
||||
|
||||
const attributes: Record<string, string> = {};
|
||||
@ -62,10 +69,14 @@ function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode {
|
||||
node.setSources(sources);
|
||||
}
|
||||
|
||||
setCommonBlockPropsFromElement(element, node);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export class MediaNode extends ElementNode {
|
||||
__id: string = '';
|
||||
__alignment: CommonBlockAlignment = '';
|
||||
__tag: MediaNodeTag;
|
||||
__attributes: Record<string, string> = {};
|
||||
__sources: MediaNodeSource[] = [];
|
||||
@ -135,11 +146,32 @@ export class MediaNode extends ElementNode {
|
||||
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) {
|
||||
const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : [];
|
||||
const sourceEls = sources.map(source => el('source', source));
|
||||
|
||||
return el(this.__tag, this.__attributes, sourceEls);
|
||||
const element = el(this.__tag, this.__attributes, sourceEls);
|
||||
updateElementWithCommonBlockProps(element, this);
|
||||
return element;
|
||||
}
|
||||
|
||||
updateDOM(prevNode: unknown, dom: HTMLElement) {
|
||||
@ -175,6 +207,8 @@ export class MediaNode extends ElementNode {
|
||||
...super.exportJSON(),
|
||||
type: 'media',
|
||||
version: 1,
|
||||
id: this.__id,
|
||||
alignment: this.__alignment,
|
||||
tag: this.__tag,
|
||||
attributes: this.__attributes,
|
||||
sources: this.__sources,
|
||||
@ -182,7 +216,10 @@ export class MediaNode extends ElementNode {
|
||||
}
|
||||
|
||||
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 el = doc.body.children[0];
|
||||
if (!el) {
|
||||
if (!(el instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -6,18 +6,19 @@
|
||||
|
||||
## 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)
|
||||
- Media resize support (like images)
|
||||
- Table caption text support
|
||||
- Mac: Shortcut support via command.
|
||||
|
||||
## Secondary Todo
|
||||
|
||||
- Color picker support in table form color fields
|
||||
- Table caption text support
|
||||
|
||||
## 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.
|
||||
|
@ -1,17 +1,32 @@
|
||||
import {$getSelection, BaseSelection} from "lexical";
|
||||
import {BaseSelection, LexicalEditor} from "lexical";
|
||||
import {EditorButtonDefinition} from "../../framework/buttons";
|
||||
import alignLeftIcon from "@icons/editor/align-left.svg";
|
||||
import {EditorUiContext} from "../../framework/core";
|
||||
import alignCenterIcon from "@icons/editor/align-center.svg";
|
||||
import alignRightIcon from "@icons/editor/align-right.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 {nodeHasAlignment} from "../../../utils/nodes";
|
||||
|
||||
|
||||
function setAlignmentForSection(alignment: CommonBlockAlignment): void {
|
||||
const selection = $getSelection();
|
||||
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!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle normal block/range alignment
|
||||
const elements = $getBlockElementNodesInSelection(selection);
|
||||
for (const node of elements) {
|
||||
if (nodeHasAlignment(node)) {
|
||||
@ -24,10 +39,10 @@ export const alignLeft: EditorButtonDefinition = {
|
||||
label: 'Align left',
|
||||
icon: alignLeftIcon,
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.update(() => setAlignmentForSection('left'));
|
||||
context.editor.update(() => setAlignmentForSection(context.editor, 'left'));
|
||||
},
|
||||
isActive(selection: BaseSelection|null) {
|
||||
return $selectionContainsElementFormat(selection, 'left');
|
||||
return $selectionContainsAlignment(selection, 'left');
|
||||
}
|
||||
};
|
||||
|
||||
@ -35,10 +50,10 @@ export const alignCenter: EditorButtonDefinition = {
|
||||
label: 'Align center',
|
||||
icon: alignCenterIcon,
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.update(() => setAlignmentForSection('center'));
|
||||
context.editor.update(() => setAlignmentForSection(context.editor, 'center'));
|
||||
},
|
||||
isActive(selection: BaseSelection|null) {
|
||||
return $selectionContainsElementFormat(selection, 'center');
|
||||
return $selectionContainsAlignment(selection, 'center');
|
||||
}
|
||||
};
|
||||
|
||||
@ -46,10 +61,10 @@ export const alignRight: EditorButtonDefinition = {
|
||||
label: 'Align right',
|
||||
icon: alignRightIcon,
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.update(() => setAlignmentForSection('right'));
|
||||
context.editor.update(() => setAlignmentForSection(context.editor, 'right'));
|
||||
},
|
||||
isActive(selection: BaseSelection|null) {
|
||||
return $selectionContainsElementFormat(selection, 'right');
|
||||
return $selectionContainsAlignment(selection, 'right');
|
||||
}
|
||||
};
|
||||
|
||||
@ -57,9 +72,9 @@ export const alignJustify: EditorButtonDefinition = {
|
||||
label: 'Align justify',
|
||||
icon: alignJustifyIcon,
|
||||
action(context: EditorUiContext) {
|
||||
context.editor.update(() => setAlignmentForSection('justify'));
|
||||
context.editor.update(() => setAlignmentForSection(context.editor, 'justify'));
|
||||
},
|
||||
isActive(selection: BaseSelection|null) {
|
||||
return $selectionContainsElementFormat(selection, 'justify');
|
||||
return $selectionContainsAlignment(selection, 'justify');
|
||||
}
|
||||
};
|
||||
|
@ -2,11 +2,11 @@ import {
|
||||
$createNodeSelection,
|
||||
$createParagraphNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$getSelection, $isDecoratorNode,
|
||||
$isElementNode,
|
||||
$isTextNode,
|
||||
$setSelection,
|
||||
BaseSelection,
|
||||
BaseSelection, DecoratorNode,
|
||||
ElementFormatType,
|
||||
ElementNode, LexicalEditor,
|
||||
LexicalNode,
|
||||
@ -16,8 +16,9 @@ import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexi
|
||||
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes";
|
||||
import {$setBlocksType} from "@lexical/selection";
|
||||
|
||||
import {$getParentOfType} from "./nodes";
|
||||
import {$getParentOfType, nodeHasAlignment} from "./nodes";
|
||||
import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
|
||||
import {CommonBlockAlignment} from "../nodes/_common";
|
||||
|
||||
const lastSelectionByEditor = new WeakMap<LexicalEditor, BaseSelection|null>;
|
||||
|
||||
@ -120,10 +121,10 @@ export function $selectionContainsNode(selection: BaseSelection | null, node: Le
|
||||
return false;
|
||||
}
|
||||
|
||||
export function $selectionContainsElementFormat(selection: BaseSelection | null, format: ElementFormatType): boolean {
|
||||
export function $selectionContainsAlignment(selection: BaseSelection | null, alignment: CommonBlockAlignment): boolean {
|
||||
const nodes = $getBlockElementNodesInSelection(selection);
|
||||
for (const node of nodes) {
|
||||
if (node.getFormatType() === format) {
|
||||
if (nodeHasAlignment(node) && node.getAlignment() === alignment) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -149,3 +150,11 @@ export function $getBlockElementNodesInSelection(selection: BaseSelection | null
|
||||
|
||||
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