Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add columns layout plugin #93

Merged
merged 3 commits into from
Sep 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/svelte-lexical/src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,10 @@ i.poll {
background-image: url(/images/icons/card-checklist.svg);
}

i.columns {
background-image: url(images/icons/3-columns.svg);
}

i.tweet {
background-image: url(/images/icons/tweet.svg);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
export let showModal: boolean;
export let stopPropagation = true;

let dialog: HTMLDialogElement;

Expand All @@ -18,10 +19,17 @@
bind:this={dialog}
on:close={() => (showModal = false)}
on:click|self={() => dialog.close()}>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click|stopPropagation>
<slot />
</div>
{#if stopPropagation}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click|stopPropagation>
<slot />
</div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click>
<slot />
</div>
{/if}
</dialog>

<style>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script lang="ts">
import DropDownItem from '../../generic/dropdown/DropDownItem.svelte';
</script>

<DropDownItem on:click class="item">
<i class="icon columns" />
<span class="text">Columns Layout</span>
</DropDownItem>
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<script lang="ts">
import {getActiveEditor} from '$lib/core/composerContext.js';
import {getCommands} from '$lib/core/commands.js';
import {getEditor} from '$lib/core/composerContext.js';
import CloseCircleButton from '../../generic/button/CloseCircleButton.svelte';
import ModalDialog from '../../generic/dialog/ModalDialog.svelte';
import {INSERT_LAYOUT_COMMAND} from '../../../core/plugins/ColumnsLayout/LayoutItemNode.js';
import DropDownItem from '../../generic/dropdown/DropDownItem.svelte';
import DropDown from '../../generic/dropdown/DropDown.svelte';

const editor = getEditor();
const activeEditor = getActiveEditor();

export let showModal = false;
export function open() {
showModal = true;
}

function close() {
showModal = false;
getCommands().FocusEditor.execute(editor);
}

const LAYOUTS = [
{label: '2 columns (equal width)', value: '1fr 1fr'},
{label: '2 columns (25% - 75%)', value: '1fr 3fr'},
{label: '3 columns (equal width)', value: '1fr 1fr 1fr'},
{label: '3 columns (25% - 50% - 25%)', value: '1fr 2fr 1fr'},
{label: '4 columns (equal width)', value: '1fr 1fr 1fr 1fr'},
];

let currentLabel = LAYOUTS[0].label;
let currentValue = LAYOUTS[0].value;
const handleClick = (label: string, value: string) => {
currentLabel = label;
currentValue = value;
};
</script>

<ModalDialog bind:showModal stopPropagation={false}>
<CloseCircleButton on:click={close} />

<div class="modal">
<h2 class="Modal__title">Insert Columns Layout</h2>
<div class="Modal__content">
<DropDown
buttonClassName="toolbar-item spaced"
buttonLabel={currentLabel}
buttonAriaLabel="Insert specialized editor node"
buttonIconClassName="">
{#each LAYOUTS as layout}
<DropDownItem
class={`item ${currentLabel === layout.label ? 'active dropdown-item-active' : ''}`}
on:click={() => {
handleClick(layout.label, layout.value);
}}>
<span class="text">{layout.label}</span>
</DropDownItem>
{/each}
</DropDown>

<div class="ToolbarPlugin__dialogActions">
<button
data-test-id="image-modal-file-upload-btn"
class="Button__root"
on:click={() => {
$activeEditor.dispatchCommand(INSERT_LAYOUT_COMMAND, currentValue);
close();
}}>
Insert
</button>
</div>
</div>
</div>
</ModalDialog>

<style>
.modal {
width: 20em;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<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(() => {
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>
Loading
Loading