Skip to content

Commit

Permalink
Columns Layout Plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
Richard Scheglmann committed Sep 14, 2024
1 parent aaf0fed commit 98e7b54
Show file tree
Hide file tree
Showing 4 changed files with 412 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<script lang="ts">
import type {ElementNode, LexicalNode} from 'lexical';
import {getEditor} from '../../composerContext.js';
import {
$findMatchingParent as findMatchingParent,
$insertNodeToNearestRoot as insertNodeToNearestRoot,
mergeRegister,
} from '@lexical/utils';
import {
$createParagraphNode as createParagraphNode,
$getNodeByKey as getNodeByKey,
$getSelection as getSelection,
$isRangeSelection as isRangeSelection,
COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_LOW,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_LEFT_COMMAND,
KEY_ARROW_RIGHT_COMMAND,
KEY_ARROW_UP_COMMAND,
} from 'lexical';
import {onMount} from 'svelte';
import {
$createLayoutContainerNode as createLayoutContainerNode,
$isLayoutContainerNode as isLayoutContainerNode,
LayoutContainerNode,
} from './LayoutContainerNode.js';
import {
$createLayoutItemNode as createLayoutItemNode,
$isLayoutItemNode as isLayoutItemNode,
LayoutItemNode,
INSERT_LAYOUT_COMMAND,
UPDATE_LAYOUT_COMMAND,
} from './LayoutItemNode.js';
const editor = getEditor();
onMount(() => {
if (!editor.hasNodes([LayoutContainerNode, LayoutItemNode])) {
throw new Error(
'LayoutPlugin: LayoutContainerNode, or LayoutItemNode not registered on editor',
);
}
const $onEscape = (before: boolean) => {
const selection = getSelection();
if (
isRangeSelection(selection) &&
selection.isCollapsed() &&
selection.anchor.offset === 0
) {
const container = findMatchingParent(
selection.anchor.getNode(),
isLayoutContainerNode,
);
if (isLayoutContainerNode(container)) {
const parent = container.getParent<ElementNode>();
const child =
parent &&
(before
? parent.getFirstChild<LexicalNode>()
: parent?.getLastChild<LexicalNode>());
const descendant = before
? container.getFirstDescendant<LexicalNode>()?.getKey()
: container.getLastDescendant<LexicalNode>()?.getKey();
if (
parent !== null &&
child === container &&
selection.anchor.key === descendant
) {
if (before) {
container.insertBefore(createParagraphNode());
} else {
container.insertAfter(createParagraphNode());
}
}
}
}
return false;
};
return mergeRegister(
// When layout is the last child pressing down/right arrow will insert paragraph
// below it to allow adding more content. It's similar what $insertBlockNode
// (mainly for decorators), except it'll always be possible to continue adding
// new content even if trailing paragraph is accidentally deleted
editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
() => $onEscape(false),
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_RIGHT_COMMAND,
() => $onEscape(false),
COMMAND_PRIORITY_LOW,
),
// When layout is the first child pressing up/left arrow will insert paragraph
// above it to allow adding more content. It's similar what $insertBlockNode
// (mainly for decorators), except it'll always be possible to continue adding
// new content even if leading paragraph is accidentally deleted
editor.registerCommand(
KEY_ARROW_UP_COMMAND,
() => $onEscape(true),
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_LEFT_COMMAND,
() => $onEscape(true),
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
INSERT_LAYOUT_COMMAND,
(template: string) => {
editor.update(() => {
console.log(template);
const container = createLayoutContainerNode(template);
const itemsCount = getItemsCountFromTemplate(template);
for (let i = 0; i < itemsCount; i++) {
container.append(
createLayoutItemNode().append(createParagraphNode()),
);
}
insertNodeToNearestRoot(container);
container.selectStart();
});
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
UPDATE_LAYOUT_COMMAND,
({template, nodeKey}) => {
editor.update(() => {
const container = getNodeByKey<LexicalNode>(nodeKey);
if (!isLayoutContainerNode(container)) {
return;
}
const itemsCount = getItemsCountFromTemplate(template);
const prevItemsCount = getItemsCountFromTemplate(
container.getTemplateColumns(),
);
// Add or remove extra columns if new template does not match existing one
if (itemsCount > prevItemsCount) {
for (let i = prevItemsCount; i < itemsCount; i++) {
container.append(
createLayoutItemNode().append(createParagraphNode()),
);
}
} else if (itemsCount < prevItemsCount) {
for (let i = prevItemsCount - 1; i >= itemsCount; i--) {
const layoutItem = container.getChildAtIndex<LexicalNode>(i);
if (isLayoutItemNode(layoutItem)) {
layoutItem.remove();
}
}
}
container.setTemplateColumns(template);
});
return true;
},
COMMAND_PRIORITY_EDITOR,
),
// Structure enforcing transformers for each node type. In case nesting structure is not
// "Container > Item" it'll unwrap nodes and convert it back
// to regular content.
editor.registerNodeTransform(LayoutItemNode, (node) => {
const parent = node.getParent<ElementNode>();
if (!isLayoutContainerNode(parent)) {
const children = node.getChildren<LexicalNode>();
for (const child of children) {
node.insertBefore(child);
}
node.remove();
}
}),
editor.registerNodeTransform(LayoutContainerNode, (node) => {
const children = node.getChildren<LexicalNode>();
if (!children.every(isLayoutItemNode)) {
for (const child of children) {
node.insertBefore(child);
}
node.remove();
}
}),
);
});
function getItemsCountFromTemplate(template: string): number {
return template.trim().split(/\s+/).length;
}
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedElementNode,
Spread,
} from 'lexical';

import {addClassNamesToElement} from '@lexical/utils';
import {ElementNode} from 'lexical';

export type SerializedLayoutContainerNode = Spread<
{
templateColumns: string;
},
SerializedElementNode
>;

function $convertLayoutContainerElement(
domNode: HTMLElement,
): DOMConversionOutput | null {
const styleAttributes = window.getComputedStyle(domNode);
const templateColumns = styleAttributes.getPropertyValue(
'grid-template-columns',
);
if (templateColumns) {
const node = $createLayoutContainerNode(templateColumns);
return {node};
}
return null;
}

export class LayoutContainerNode extends ElementNode {
__templateColumns: string;

constructor(templateColumns: string, key?: NodeKey) {
super(key);
this.__templateColumns = templateColumns;
}

static getType(): string {
return 'layout-container';
}

static clone(node: LayoutContainerNode): LayoutContainerNode {
return new LayoutContainerNode(node.__templateColumns, node.__key);
}

createDOM(config: EditorConfig): HTMLElement {
const dom = document.createElement('div');
dom.style.gridTemplateColumns = this.__templateColumns;
if (typeof config.theme.layoutContainer === 'string') {
addClassNamesToElement(dom, config.theme.layoutContainer);
}
return dom;
}

exportDOM(): DOMExportOutput {
const element = document.createElement('div');
element.style.gridTemplateColumns = this.__templateColumns;
element.setAttribute('data-lexical-layout-container', 'true');
return {element};
}

updateDOM(prevNode: LayoutContainerNode, dom: HTMLElement): boolean {
if (prevNode.__templateColumns !== this.__templateColumns) {
dom.style.gridTemplateColumns = this.__templateColumns;
}
return false;
}

static importDOM(): DOMConversionMap | null {
return {
div: (domNode: HTMLElement) => {
if (!domNode.hasAttribute('data-lexical-layout-container')) {
return null;
}
return {
conversion: $convertLayoutContainerElement,
priority: 2,
};
},
};
}

static importJSON(json: SerializedLayoutContainerNode): LayoutContainerNode {
return $createLayoutContainerNode(json.templateColumns);
}

isShadowRoot(): boolean {
return true;
}

canBeEmpty(): boolean {
return false;
}

exportJSON(): SerializedLayoutContainerNode {
return {
...super.exportJSON(),
templateColumns: this.__templateColumns,
type: 'layout-container',
version: 1,
};
}

getTemplateColumns(): string {
return this.getLatest().__templateColumns;
}

setTemplateColumns(templateColumns: string) {
this.getWritable().__templateColumns = templateColumns;
}
}

export function $createLayoutContainerNode(
templateColumns: string,
): LayoutContainerNode {
return new LayoutContainerNode(templateColumns);
}

export function $isLayoutContainerNode(
node: LexicalNode | null | undefined,
): node is LayoutContainerNode {
return node instanceof LayoutContainerNode;
}
Loading

0 comments on commit 98e7b54

Please sign in to comment.