mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: columns
This commit is contained in:
@@ -363,6 +363,15 @@
|
|||||||
"Heading {{level}}": "Heading {{level}}",
|
"Heading {{level}}": "Heading {{level}}",
|
||||||
"Toggle title": "Toggle title",
|
"Toggle title": "Toggle title",
|
||||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
"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",
|
"Names do not match": "Names do not match",
|
||||||
"Today, {{time}}": "Today, {{time}}",
|
"Today, {{time}}": "Today, {{time}}",
|
||||||
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={rem(size)}
|
||||||
|
height={rem(size)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||||
|
<path d="M7.5 3v18" />
|
||||||
|
<path d="M12 3v18" />
|
||||||
|
<path d="M16.5 3v18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={rem(size)}
|
||||||
|
height={rem(size)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||||
|
<path d="M6.6 3v18" />
|
||||||
|
<path d="M10.2 3v18" />
|
||||||
|
<path d="M13.8 3v18" />
|
||||||
|
<path d="M17.4 3v18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<BaseBubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
pluginKey="columns-menu"
|
||||||
|
updateDelay={0}
|
||||||
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
|
options={{
|
||||||
|
placement: "bottom",
|
||||||
|
offset: {
|
||||||
|
mainAxis: 5,
|
||||||
|
},
|
||||||
|
flip: false,
|
||||||
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
|
>
|
||||||
|
<div className={classes.toolbar}>
|
||||||
|
<Popover opened={isCountOpen} onChange={setIsCountOpen} withArrow>
|
||||||
|
<Popover.Target>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="dark"
|
||||||
|
size="compact-sm"
|
||||||
|
rightSection={<IconChevronDown size={12} />}
|
||||||
|
onClick={() => setIsCountOpen(!isCountOpen)}
|
||||||
|
aria-label={t("Column count")}
|
||||||
|
>
|
||||||
|
{t("{{count}} Columns", { count: columnCount })}
|
||||||
|
</Button>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown p={4}>
|
||||||
|
<Button.Group orientation="vertical">
|
||||||
|
{[2, 3, 4, 5].map((n) => (
|
||||||
|
<Button
|
||||||
|
key={n}
|
||||||
|
variant={n === columnCount ? "light" : "subtle"}
|
||||||
|
color={n === columnCount ? "blue" : "dark"}
|
||||||
|
justify="space-between"
|
||||||
|
fullWidth
|
||||||
|
rightSection={
|
||||||
|
n === columnCount ? <IconCheck size={14} /> : null
|
||||||
|
}
|
||||||
|
onClick={() => setColumnCount(n)}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{t("{{count}} Columns", { count: n })}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Button.Group>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{presets.length > 0 && <div className={classes.divider} />}
|
||||||
|
|
||||||
|
{presets.map((preset) => (
|
||||||
|
<Tooltip key={preset.layout} position="top" label={t(preset.label)}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => setLayout(preset.layout)}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t(preset.label)}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: currentLayout === preset.layout,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<preset.icon size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ColumnsMenu;
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconAppWindow,
|
IconAppWindow,
|
||||||
IconSitemap,
|
IconSitemap,
|
||||||
|
IconColumns3,
|
||||||
|
IconColumns2,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
@@ -31,6 +33,8 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/
|
|||||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||||
import IconDrawio from "@/components/icons/icon-drawio";
|
import IconDrawio from "@/components/icons/icon-drawio";
|
||||||
|
import { IconColumns4 } from "@/components/icons/icon-columns-4";
|
||||||
|
import { IconColumns5 } from "@/components/icons/icon-columns-5";
|
||||||
import {
|
import {
|
||||||
AirtableIcon,
|
AirtableIcon,
|
||||||
FigmaIcon,
|
FigmaIcon,
|
||||||
@@ -390,6 +394,58 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
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",
|
title: "Iframe embed",
|
||||||
description: "Embed any Iframe",
|
description: "Embed any Iframe",
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import {
|
|||||||
Highlight,
|
Highlight,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
SharedStorage,
|
SharedStorage,
|
||||||
|
Columns,
|
||||||
|
Column,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -124,7 +126,7 @@ export const mainExtensions = [
|
|||||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ node }) => {
|
placeholder: ({ editor, node, pos }) => {
|
||||||
if (node.type.name === "heading") {
|
if (node.type.name === "heading") {
|
||||||
return i18n.t("Heading {{level}}", { level: node.attrs.level });
|
return i18n.t("Heading {{level}}", { level: node.attrs.level });
|
||||||
}
|
}
|
||||||
@@ -132,6 +134,10 @@ export const mainExtensions = [
|
|||||||
return i18n.t("Toggle title");
|
return i18n.t("Toggle title");
|
||||||
}
|
}
|
||||||
if (node.type.name === "paragraph") {
|
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');
|
return i18n.t('Write anything. Enter "/" for commands');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -302,6 +308,8 @@ export const mainExtensions = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}).configure(),
|
}).configure(),
|
||||||
|
Columns,
|
||||||
|
Column,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import { jwtDecode } from "jwt-decode";
|
|||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
||||||
|
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -416,6 +417,7 @@ export default function PageEditor({
|
|||||||
<SubpagesMenu editor={editor} />
|
<SubpagesMenu editor={editor} />
|
||||||
<ExcalidrawMenu editor={editor} />
|
<ExcalidrawMenu editor={editor} />
|
||||||
<DrawioMenu editor={editor} />
|
<DrawioMenu editor={editor} />
|
||||||
|
<ColumnsMenu editor={editor} />
|
||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,3 +13,4 @@
|
|||||||
@import "./mention.css";
|
@import "./mention.css";
|
||||||
@import "./ordered-list.css";
|
@import "./ordered-list.css";
|
||||||
@import "./highlight.css";
|
@import "./highlight.css";
|
||||||
|
@import "./columns.css";
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import {
|
|||||||
UniqueID,
|
UniqueID,
|
||||||
addUniqueIdsToDoc,
|
addUniqueIdsToDoc,
|
||||||
htmlToMarkdown,
|
htmlToMarkdown,
|
||||||
|
Columns,
|
||||||
|
Column,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||||
@@ -91,6 +93,8 @@ export const tiptapExtensions = [
|
|||||||
Embed,
|
Embed,
|
||||||
Mention,
|
Mention,
|
||||||
Subpages,
|
Subpages,
|
||||||
|
Columns,
|
||||||
|
Column,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: any) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
|
|||||||
@@ -25,3 +25,4 @@ export * from "./lib/heading/heading";
|
|||||||
export * from "./lib/unique-id";
|
export * from "./lib/unique-id";
|
||||||
export * from "./lib/shared-storage";
|
export * from "./lib/shared-storage";
|
||||||
export * from "./lib/recreate-transform";
|
export * from "./lib/recreate-transform";
|
||||||
|
export * from "./lib/columns";
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
|
||||||
|
import { TextSelection } from "prosemirror-state";
|
||||||
|
|
||||||
|
export interface ColumnOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnAttributes {
|
||||||
|
width?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
column: {
|
||||||
|
setColumnWidth: (width: number | null) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Column = Node.create<ColumnOptions>({
|
||||||
|
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 }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WidthMode = "normal" | "wide";
|
||||||
|
|
||||||
|
export interface ColumnsAttributes {
|
||||||
|
layout?: ColumnsLayout;
|
||||||
|
widthMode?: WidthMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
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<ColumnsOptions>({
|
||||||
|
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 }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
Reference in New Issue
Block a user