From 993d771282e99ff8f6ad048f3b7e2bdddb3d10ff Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:01:40 +0000 Subject: [PATCH] feat: columns --- .../public/locales/en-US/translation.json | 9 + .../src/components/icons/icon-columns-4.tsx | 27 ++ .../src/components/icons/icon-columns-5.tsx | 28 ++ .../components/columns/columns-menu.tsx | 255 ++++++++++++++++++ .../components/slash-menu/menu-items.ts | 56 ++++ .../features/editor/extensions/extensions.ts | 10 +- .../src/features/editor/page-editor.tsx | 2 + .../src/features/editor/styles/columns.css | 108 ++++++++ .../src/features/editor/styles/index.css | 1 + .../src/collaboration/collaboration.util.ts | 4 + packages/editor-ext/src/index.ts | 1 + packages/editor-ext/src/lib/columns/column.ts | 127 +++++++++ .../editor-ext/src/lib/columns/columns.ts | 178 ++++++++++++ packages/editor-ext/src/lib/columns/index.ts | 4 + 14 files changed, 809 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/components/icons/icon-columns-4.tsx create mode 100644 apps/client/src/components/icons/icon-columns-5.tsx create mode 100644 apps/client/src/features/editor/components/columns/columns-menu.tsx create mode 100644 apps/client/src/features/editor/styles/columns.css create mode 100644 packages/editor-ext/src/lib/columns/column.ts create mode 100644 packages/editor-ext/src/lib/columns/columns.ts create mode 100644 packages/editor-ext/src/lib/columns/index.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index e46dd2c8..1588d301 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -363,6 +363,15 @@ "Heading {{level}}": "Heading {{level}}", "Toggle title": "Toggle title", "Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands", + "Write...": "Write...", + "Column count": "Column count", + "{{count}} Columns": "{{count}} Columns", + "Equal columns": "Equal columns", + "Left sidebar": "Left sidebar", + "Right sidebar": "Right sidebar", + "Wide center": "Wide center", + "Left wide": "Left wide", + "Right wide": "Right wide", "Names do not match": "Names do not match", "Today, {{time}}": "Today, {{time}}", "Yesterday, {{time}}": "Yesterday, {{time}}", diff --git a/apps/client/src/components/icons/icon-columns-4.tsx b/apps/client/src/components/icons/icon-columns-4.tsx new file mode 100644 index 00000000..d2b4541b --- /dev/null +++ b/apps/client/src/components/icons/icon-columns-4.tsx @@ -0,0 +1,27 @@ +import { rem } from "@mantine/core"; + +type Props = { + size?: number | string; + stroke?: number; +}; + +export function IconColumns4({ size = 24, stroke = 2 }: Props) { + return ( + + + + + + + ); +} diff --git a/apps/client/src/components/icons/icon-columns-5.tsx b/apps/client/src/components/icons/icon-columns-5.tsx new file mode 100644 index 00000000..afa4773c --- /dev/null +++ b/apps/client/src/components/icons/icon-columns-5.tsx @@ -0,0 +1,28 @@ +import { rem } from "@mantine/core"; + +type Props = { + size?: number | string; + stroke?: number; +}; + +export function IconColumns5({ size = 24, stroke = 2 }: Props) { + return ( + + + + + + + + ); +} diff --git a/apps/client/src/features/editor/components/columns/columns-menu.tsx b/apps/client/src/features/editor/components/columns/columns-menu.tsx new file mode 100644 index 00000000..c32b0c74 --- /dev/null +++ b/apps/client/src/features/editor/components/columns/columns-menu.tsx @@ -0,0 +1,255 @@ +import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; +import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; +import React, { useCallback, useState } from "react"; +import { Node as PMNode } from "prosemirror-model"; +import { + EditorMenuProps, + ShouldShowProps, +} from "@/features/editor/components/table/types/types.ts"; +import { ActionIcon, Tooltip, Popover, Button } from "@mantine/core"; +import clsx from "clsx"; +import { + IconChevronDown, + IconCheck, + IconColumns2, + IconColumns3, + IconLayoutSidebar, + IconLayoutSidebarRight, + IconLayoutAlignCenter, +} from "@tabler/icons-react"; +import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext"; +import { useTranslation } from "react-i18next"; +import classes from "../common/toolbar-menu.module.css"; + +type LayoutPreset = { + layout: ColumnsLayout; + label: string; + icon: React.ElementType; +}; + +const twoColumnPresets: LayoutPreset[] = [ + { layout: "two_equal", label: "Equal columns", icon: IconColumns2 }, + { + layout: "two_left_sidebar", + label: "Left sidebar", + icon: IconLayoutSidebar, + }, + { + layout: "two_right_sidebar", + label: "Right sidebar", + icon: IconLayoutSidebarRight, + }, +]; + +const threeColumnPresets: LayoutPreset[] = [ + { layout: "three_equal", label: "Equal columns", icon: IconColumns3 }, + { + layout: "three_with_sidebars", + label: "Wide center", + icon: IconLayoutAlignCenter, + }, + { + layout: "three_left_wide", + label: "Left wide", + icon: IconLayoutSidebarRight, + }, + { layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar + }, +]; + +function getPresetsForCount(count: number): LayoutPreset[] { + if (count === 2) return twoColumnPresets; + if (count === 3) return threeColumnPresets; + return []; +} + +export function ColumnsMenu({ editor }: EditorMenuProps) { + const { t } = useTranslation(); + const [isCountOpen, setIsCountOpen] = useState(false); + + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) return false; + if (!editor.isActive("columns")) return false; + + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(state.selection); + if (!parent) return false; + + const dom = editor.view.nodeDOM(parent.pos) as HTMLElement; + if (!dom) return false; + + const rect = dom.getBoundingClientRect(); + return rect.bottom > 0 && rect.top < window.innerHeight; + }, + [editor], + ); + + const editorState = useEditorState({ + editor, + selector: (ctx) => { + if (!ctx.editor) return null; + + const { selection } = ctx.editor.state; + const parent = findParentNode( + (node: PMNode) => node.type.name === "columns", + )(selection); + + return { + columnCount: parent?.node.childCount || 2, + layout: (parent?.node.attrs.layout as ColumnsLayout) || "two_equal", + isNormal: ctx.editor.isActive("columns", { widthMode: "normal" }), + isWide: ctx.editor.isActive("columns", { widthMode: "wide" }), + }; + }, + }); + + const getReferencedVirtualElement = useCallback(() => { + if (!editor) return; + const { selection } = editor.state; + const predicate = (node: PMNode) => node.type.name === "columns"; + const parent = findParentNode(predicate)(selection); + + if (parent) { + const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; + const domRect = dom.getBoundingClientRect(); + + // Columns entirely out of viewport — return real rect so menu goes off-screen + if (domRect.bottom <= 0 || domRect.top >= window.innerHeight) { + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + } + + // Clamp bottom so menu stays within viewport when columns extend below it + // 55px = 15px offset + ~40px menu height + const maxBottom = window.innerHeight - 55; + if (domRect.bottom > maxBottom) { + const clamped = new DOMRect( + domRect.x, + domRect.y, + domRect.width, + maxBottom - domRect.y, + ); + return { + getBoundingClientRect: () => clamped, + getClientRects: () => [clamped], + }; + } + + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + } + + const domRect = posToDOMRect(editor.view, selection.from, selection.to); + return { + getBoundingClientRect: () => domRect, + getClientRects: () => [domRect], + }; + }, [editor]); + + const setColumnCount = useCallback( + (count: number) => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setColumnCount(count) + .run(); + setIsCountOpen(false); + }, + [editor], + ); + + const setLayout = useCallback( + (layout: ColumnsLayout) => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setColumnsLayout(layout) + .run(); + }, + [editor], + ); + + const columnCount = editorState?.columnCount || 2; + const currentLayout = editorState?.layout || "two_equal"; + const presets = getPresetsForCount(columnCount); + + return ( + +
+ + + + + + + {[2, 3, 4, 5].map((n) => ( + + ))} + + + + + {presets.length > 0 &&
} + + {presets.map((preset) => ( + + setLayout(preset.layout)} + size="lg" + aria-label={t(preset.label)} + variant="subtle" + className={clsx({ + [classes.active]: currentLayout === preset.layout, + })} + > + + + + ))} +
+ + ); +} + +export default ColumnsMenu; diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 27793f62..03ba7b80 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -20,6 +20,8 @@ import { IconCalendar, IconAppWindow, IconSitemap, + IconColumns3, + IconColumns2, } from "@tabler/icons-react"; import { CommandProps, @@ -31,6 +33,8 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/ import IconExcalidraw from "@/components/icons/icon-excalidraw"; import IconMermaid from "@/components/icons/icon-mermaid"; import IconDrawio from "@/components/icons/icon-drawio"; +import { IconColumns4 } from "@/components/icons/icon-columns-4"; +import { IconColumns5 } from "@/components/icons/icon-columns-5"; import { AirtableIcon, FigmaIcon, @@ -390,6 +394,58 @@ const CommandGroups: SlashMenuGroupedItemsType = { editor.chain().focus().deleteRange(range).insertSubpages().run(); }, }, + { + title: "2 Columns", + description: "Split content into two columns.", + searchTerms: ["columns", "layout", "split", "side"], + icon: IconColumns2, + command: ({ editor, range }: CommandProps) => + editor + .chain() + .focus() + .deleteRange(range) + .insertColumns({ layout: "two_equal" }) + .run(), + }, + { + title: "3 Columns", + description: "Split content into three columns.", + searchTerms: ["columns", "layout", "split", "triple"], + icon: IconColumns3, + command: ({ editor, range }: CommandProps) => + editor + .chain() + .focus() + .deleteRange(range) + .insertColumns({ layout: "three_equal" }) + .run(), + }, + { + title: "4 Columns", + description: "Split content into four columns.", + searchTerms: ["columns", "layout", "split"], + icon: IconColumns4, + command: ({ editor, range }: CommandProps) => + editor + .chain() + .focus() + .deleteRange(range) + .insertColumns({ layout: "four_equal" }) + .run(), + }, + { + title: "5 Columns", + description: "Split content into five columns.", + searchTerms: ["columns", "layout", "split"], + icon: IconColumns5, + command: ({ editor, range }: CommandProps) => + editor + .chain() + .focus() + .deleteRange(range) + .insertColumns({ layout: "five_equal" }) + .run(), + }, { title: "Iframe embed", description: "Embed any Iframe", diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index ca5572d8..714878b9 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -43,6 +43,8 @@ import { Highlight, UniqueID, SharedStorage, + Columns, + Column, } from "@docmost/editor-ext"; import { randomElement, @@ -124,7 +126,7 @@ export const mainExtensions = [ filterTransaction: (transaction) => !isChangeOrigin(transaction), }), Placeholder.configure({ - placeholder: ({ node }) => { + placeholder: ({ editor, node, pos }) => { if (node.type.name === "heading") { return i18n.t("Heading {{level}}", { level: node.attrs.level }); } @@ -132,6 +134,10 @@ export const mainExtensions = [ return i18n.t("Toggle title"); } if (node.type.name === "paragraph") { + const $pos = editor.state.doc.resolve(pos); + if ($pos.parent.type.name === "column") { + return i18n.t("Write..."); + } return i18n.t('Write anything. Enter "/" for commands'); } }, @@ -302,6 +308,8 @@ export const mainExtensions = [ }; }, }).configure(), + Columns, + Column, ] as any; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index ed7ccecd..d0d1de03 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -67,6 +67,7 @@ import { jwtDecode } from "jwt-decode"; import { searchSpotlight } from "@/features/search/constants.ts"; import { useEditorScroll } from "./hooks/use-editor-scroll"; import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; +import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; interface PageEditorProps { pageId: string; @@ -416,6 +417,7 @@ export default function PageEditor({ +
)} diff --git a/apps/client/src/features/editor/styles/columns.css b/apps/client/src/features/editor/styles/columns.css new file mode 100644 index 00000000..a1fc5076 --- /dev/null +++ b/apps/client/src/features/editor/styles/columns.css @@ -0,0 +1,108 @@ +div[data-type="columns"] { + display: flex; + gap: 1rem; + margin: 0.75rem 0; + padding: 0.5em; +} + +div[data-type="columns"] > div[data-type="column"] { + flex: 1; + min-width: 0; +} + +div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] { + border-left: 1px solid transparent; + padding-left: 1rem; + transition: border 0.3s; +} + +div[data-type="columns"]:hover + > div[data-type="column"] + + div[data-type="column"] { + border-left: 1px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-7)); +} + +/* Confluence layout types */ +div[data-type="columns"][data-layout="two_left_sidebar"] + > div[data-type="column"]:first-child { + flex: 1; +} + +div[data-type="columns"][data-layout="two_left_sidebar"] + > div[data-type="column"]:last-child { + flex: 2; +} + +div[data-type="columns"][data-layout="two_right_sidebar"] + > div[data-type="column"]:first-child { + flex: 2; +} + +div[data-type="columns"][data-layout="two_right_sidebar"] + > div[data-type="column"]:last-child { + flex: 1; +} + +div[data-type="columns"][data-layout="three_left_wide"] + > div[data-type="column"]:first-child { + flex: 2; +} + +div[data-type="columns"][data-layout="three_right_wide"] + > div[data-type="column"]:last-child { + flex: 2; +} + +div[data-type="columns"][data-layout="three_with_sidebars"] + > div[data-type="column"]:first-child, +div[data-type="columns"][data-layout="three_with_sidebars"] + > div[data-type="column"]:last-child { + flex: 1; +} + +div[data-type="columns"][data-layout="three_with_sidebars"] + > div[data-type="column"]:nth-child(2) { + flex: 2; +} + +/* Stack columns vertically on small viewports */ +@media (max-width: 820px) { + div[data-type="columns"] { + flex-direction: column; + } + + div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] { + border-left: none; + padding-left: 0; + } + + div[data-type="columns"]:hover + > div[data-type="column"] + + div[data-type="column"] { + border-left: none; + } +} + +/* Wide width mode — extends columns to full container width */ +div[data-type="columns"][data-width-mode="wide"] { + margin-left: -3rem; + margin-right: -3rem; + width: calc(100% + 6rem); +} + +@media (max-width: $mantine-breakpoint-sm) { + div[data-type="columns"][data-width-mode="wide"] { + margin-left: -1rem; + margin-right: -1rem; + width: calc(100% + 2rem); + } +} + +@media print { + div[data-type="columns"][data-width-mode="wide"] { + margin-left: 0; + margin-right: 0; + width: 100%; + } +} diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index e32a606f..120c2a10 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -13,3 +13,4 @@ @import "./mention.css"; @import "./ordered-list.css"; @import "./highlight.css"; +@import "./columns.css"; diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index a29bb22a..9f173d44 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -35,6 +35,8 @@ import { UniqueID, addUniqueIdsToDoc, htmlToMarkdown, + Columns, + Column, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; @@ -91,6 +93,8 @@ export const tiptapExtensions = [ Embed, Mention, Subpages, + Columns, + Column, ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 102cc4b1..feb7e488 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -25,3 +25,4 @@ export * from "./lib/heading/heading"; export * from "./lib/unique-id"; export * from "./lib/shared-storage"; export * from "./lib/recreate-transform"; +export * from "./lib/columns"; diff --git a/packages/editor-ext/src/lib/columns/column.ts b/packages/editor-ext/src/lib/columns/column.ts new file mode 100644 index 00000000..c6aace46 --- /dev/null +++ b/packages/editor-ext/src/lib/columns/column.ts @@ -0,0 +1,127 @@ +import { Node, mergeAttributes, findParentNode } from "@tiptap/core"; +import { TextSelection } from "prosemirror-state"; + +export interface ColumnOptions { + HTMLAttributes: Record; +} + +export interface ColumnAttributes { + width?: number | null; +} + +declare module "@tiptap/core" { + interface Commands { + column: { + setColumnWidth: (width: number | null) => ReturnType; + }; + } +} + +export const Column = Node.create({ + name: "column", + group: "block", + content: "block+", + defining: true, + isolating: true, + selectable: false, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + width: { + default: null, + parseHTML: (element) => { + const value = element.getAttribute("data-width"); + return value ? parseFloat(value) : null; + }, + renderHTML: (attributes: ColumnAttributes) => { + if (!attributes.width) return {}; + return { + "data-width": attributes.width, + style: `flex: ${attributes.width}`, + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + 0, + ]; + }, + + addKeyboardShortcuts() { + const jumpToColumn = (direction: 1 | -1) => () => { + const { state, dispatch } = this.editor.view; + + const columns = findParentNode( + (node) => node.type.name === "columns", + )(state.selection); + if (!columns) return false; + + const column = findParentNode( + (node) => node.type.name === "column", + )(state.selection); + if (!column) return false; + + let currentIndex = -1; + columns.node.forEach((_child, offset, index) => { + if (columns.pos + 1 + offset === column.pos) { + currentIndex = index; + } + }); + + const targetIndex = currentIndex + direction; + if (targetIndex < 0 || targetIndex >= columns.node.childCount) { + return false; + } + + let offset = 0; + for (let j = 0; j < targetIndex; j++) { + offset += columns.node.child(j).nodeSize; + } + + const targetPos = columns.pos + 1 + offset + 1 + 1; + if (dispatch) { + dispatch( + state.tr.setSelection(TextSelection.create(state.doc, targetPos)), + ); + } + return true; + }; + + return { + Tab: jumpToColumn(1), + "Shift-Tab": jumpToColumn(-1), + }; + }, + + addCommands() { + return { + setColumnWidth: + (width) => + ({ commands }) => + commands.updateAttributes("column", { width }), + }; + }, +}); diff --git a/packages/editor-ext/src/lib/columns/columns.ts b/packages/editor-ext/src/lib/columns/columns.ts new file mode 100644 index 00000000..3853a967 --- /dev/null +++ b/packages/editor-ext/src/lib/columns/columns.ts @@ -0,0 +1,178 @@ +import { Node, mergeAttributes, findParentNode } from "@tiptap/core"; +import { Fragment, Node as PMNode } from "prosemirror-model"; + +export type ColumnsLayout = + | "two_equal" + | "two_left_sidebar" + | "two_right_sidebar" + | "three_equal" + | "three_left_wide" + | "three_right_wide" + | "three_with_sidebars" + | "four_equal" + | "five_equal"; + +export interface ColumnsOptions { + HTMLAttributes: Record; +} + +export type WidthMode = "normal" | "wide"; + +export interface ColumnsAttributes { + layout?: ColumnsLayout; + widthMode?: WidthMode; +} + +declare module "@tiptap/core" { + interface Commands { + columns: { + insertColumns: (attributes?: ColumnsAttributes) => ReturnType; + setColumnsWidthMode: (widthMode: WidthMode) => ReturnType; + setColumnCount: (count: number) => ReturnType; + setColumnsLayout: (layout: ColumnsLayout) => ReturnType; + }; + } +} + +function columnCountFromLayout(layout: string): number { + if (layout.startsWith("five")) return 5; + if (layout.startsWith("four")) return 4; + if (layout.startsWith("three")) return 3; + return 2; +} + +function defaultLayoutForCount(count: number): ColumnsLayout { + if (count === 3) return "three_equal"; + if (count === 4) return "four_equal"; + if (count === 5) return "five_equal"; + return "two_equal"; +} + +export const Columns = Node.create({ + name: "columns", + group: "block", + content: "column+", + defining: true, + isolating: true, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + layout: { + default: "two_equal", + parseHTML: (element) => element.getAttribute("data-layout"), + renderHTML: (attributes: ColumnsAttributes) => ({ + "data-layout": attributes.layout, + }), + }, + widthMode: { + default: "normal", + parseHTML: (element) => + element.getAttribute("data-width-mode") || "normal", + renderHTML: (attributes: ColumnsAttributes) => { + if (!attributes.widthMode || attributes.widthMode === "normal") + return {}; + return { "data-width-mode": attributes.widthMode }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: `div[data-type="${this.name}"]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + { "data-type": this.name }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + 0, + ]; + }, + + addCommands() { + return { + insertColumns: + (attributes) => + ({ commands }) => { + const layout = attributes?.layout || "two_equal"; + const count = columnCountFromLayout(layout); + const columns = Array.from({ length: count }, () => ({ + type: "column", + content: [{ type: "paragraph" }], + })); + + return commands.insertContent({ + type: this.name, + attrs: attributes, + content: columns, + }); + }, + + setColumnsWidthMode: + (widthMode) => + ({ commands }) => + commands.updateAttributes("columns", { widthMode }), + + setColumnCount: + (count: number) => + ({ tr, state }) => { + const predicate = (node: PMNode) => node.type.name === "columns"; + const parent = findParentNode(predicate)(state.selection); + if (!parent) return false; + + const { node: columnsNode, pos: parentPos } = parent; + const currentCount = columnsNode.childCount; + if (count === currentCount || count < 2 || count > 5) return false; + + const columnType = state.schema.nodes.column; + const paraType = state.schema.nodes.paragraph; + const newChildren: PMNode[] = []; + + if (count > currentCount) { + for (let i = 0; i < currentCount; i++) { + newChildren.push(columnsNode.child(i)); + } + for (let i = currentCount; i < count; i++) { + newChildren.push(columnType.create(null, paraType.create())); + } + } else { + for (let i = 0; i < count - 1; i++) { + newChildren.push(columnsNode.child(i)); + } + let mergedContent = columnsNode.child(count - 1).content; + for (let j = count; j < currentCount; j++) { + mergedContent = mergedContent.append(columnsNode.child(j).content); + } + newChildren.push(columnType.create(null, mergedContent)); + } + + const newLayout = defaultLayoutForCount(count); + const newNode = columnsNode.type.create( + { ...columnsNode.attrs, layout: newLayout }, + Fragment.from(newChildren), + ); + tr.replaceWith(parentPos, parentPos + columnsNode.nodeSize, newNode); + return true; + }, + + setColumnsLayout: + (layout) => + ({ commands }) => + commands.updateAttributes("columns", { layout }), + }; + }, +}); diff --git a/packages/editor-ext/src/lib/columns/index.ts b/packages/editor-ext/src/lib/columns/index.ts new file mode 100644 index 00000000..e7af35b6 --- /dev/null +++ b/packages/editor-ext/src/lib/columns/index.ts @@ -0,0 +1,4 @@ +export { Columns } from "./columns"; +export type { ColumnsOptions, ColumnsAttributes, ColumnsLayout, WidthMode } from "./columns"; +export { Column } from "./column"; +export type { ColumnOptions, ColumnAttributes } from "./column";