Compare commits

...

6 Commits

Author SHA1 Message Date
Philipinho c0df96d4bb update packages 2026-02-25 23:04:18 +00:00
Philipinho 22f33bab7c cleanups 2026-02-25 22:41:54 +00:00
Philipinho e0a8521566 enhance columns 2026-02-25 22:31:01 +00:00
Philip Okugbe b5803f42da xwiki html import cleanup (#1969) 2026-02-24 15:53:38 +00:00
Olivier Lambert 5de1c8e3ed fix: inline code input rule deletes character before opening backtick (#1923)
The upstream TipTap Code extension input rule regex /(^|[^`])`([^`]+)`(?!`)$/
uses a capture group (^|[^`]) that includes the character preceding the
opening backtick in the full match. When markInputRule processes this,
it deletes everything from the match start to the code content, which
removes that preceding character along with the backtick delimiters.

For example, typing foo(`bar` would result in foo`bar` (formatted)
instead of the expected foo(`bar` (formatted) — the ( is lost.

Fix: disable the built-in Code extension from StarterKit and register it
separately with a corrected regex that uses a lookbehind assertion
(?:^|(?<=[^`])) instead of a capture group. The lookbehind asserts the
preceding character without including it in the match, so markInputRule
only deletes the backtick delimiters.

Functionally tested on Firefox and Chrome.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:51:24 +00:00
Philip Okugbe ef87210b3d feat: editor UI refresh and enhancements (#1968)
* feat: new image menu
* switch to resizable side handles
* use pixels

* refactor excalidraw and drawio menu

* support image resize undo

* video resize

* callout menu refresh

* refresh table menus

* fix color scheme

* fix: patch @tiptap/core ResizableNodeView to prevent resize sticking after mouseup

* feat: columns

* notes callout

* focus on first column

* capture tab key in column

* fix print

* hide columns menu when some nodes are focused

* fix print

* fix columns

* selective placeholder

* fix blockquote

* quote

* fix callout in columns
2026-02-24 15:22:37 +00:00
46 changed files with 4673 additions and 1987 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.16.0", "@eslint/js": "^9.16.0",
"@tanstack/eslint-plugin-query": "^5.62.1", "@tanstack/eslint-plugin-query": "^5.91.4",
"@types/blueimp-load-image": "^5.16.0", "@types/blueimp-load-image": "^5.16.0",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
@@ -274,6 +274,7 @@
"Add row below": "Add row below", "Add row below": "Add row below",
"Delete table": "Delete table", "Delete table": "Delete table",
"Info": "Info", "Info": "Info",
"Note": "Note",
"Success": "Success", "Success": "Success",
"Warning": "Warning", "Warning": "Warning",
"Danger": "Danger", "Danger": "Danger",
@@ -363,6 +364,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>
);
}
@@ -1,22 +1,25 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import clsx from "clsx";
import { import {
IconAlertTriangleFilled, IconAlertTriangleFilled,
IconCircleCheckFilled, IconCircleCheckFilled,
IconCircleXFilled, IconCircleXFilled,
IconInfoCircleFilled, IconInfoCircleFilled,
IconMoodSmile, IconMoodSmile,
IconNotes,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { CalloutType } from "@docmost/editor-ext"; import { CalloutType, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx"; import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import classes from "../common/toolbar-menu.module.css";
export function CalloutMenu({ editor }: EditorMenuProps) { export function CalloutMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -26,6 +29,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
if (!state) { if (!state) {
return false; return false;
} }
if (isTextSelected(editor)) return false;
return editor.isActive("callout"); return editor.isActive("callout");
}, },
@@ -42,6 +46,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
return { return {
isCallout: ctx.editor.isActive("callout"), isCallout: ctx.editor.isActive("callout"),
isInfo: ctx.editor.isActive("callout", { type: "info" }), isInfo: ctx.editor.isActive("callout", { type: "info" }),
isNote: ctx.editor.isActive("callout", { type: "note" }),
isSuccess: ctx.editor.isActive("callout", { type: "success" }), isSuccess: ctx.editor.isActive("callout", { type: "success" }),
isWarning: ctx.editor.isActive("callout", { type: "warning" }), isWarning: ctx.editor.isActive("callout", { type: "warning" }),
isDanger: ctx.editor.isActive("callout", { type: "danger" }), isDanger: ctx.editor.isActive("callout", { type: "danger" }),
@@ -126,15 +131,31 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group className="actionIconGroup"> <div className={classes.toolbar}>
<Tooltip position="top" label={t("Info")}> <Tooltip position="top" label={t("Info")}>
<ActionIcon <ActionIcon
onClick={() => setCalloutType("info")} onClick={() => setCalloutType("info")}
size="lg" size="lg"
aria-label={t("Info")} aria-label={t("Info")}
variant={editorState?.isInfo ? "light" : "default"} variant="subtle"
className={clsx({ [classes.active]: editorState?.isInfo })}
> >
<IconInfoCircleFilled size={18} /> <IconInfoCircleFilled
size={18}
color="var(--mantine-color-blue-5)"
/>
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Note")}>
<ActionIcon
onClick={() => setCalloutType("note")}
size="lg"
aria-label={t("Note")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isNote })}
>
<IconNotes size={18} color="var(--mantine-color-grape-5)" />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -143,9 +164,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("success")} onClick={() => setCalloutType("success")}
size="lg" size="lg"
aria-label={t("Success")} aria-label={t("Success")}
variant={editorState?.isSuccess ? "light" : "default"} variant="subtle"
className={clsx({ [classes.active]: editorState?.isSuccess })}
> >
<IconCircleCheckFilled size={18} /> <IconCircleCheckFilled
size={18}
color="var(--mantine-color-green-5)"
/>
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -154,9 +179,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("warning")} onClick={() => setCalloutType("warning")}
size="lg" size="lg"
aria-label={t("Warning")} aria-label={t("Warning")}
variant={editorState?.isWarning ? "light" : "default"} variant="subtle"
className={clsx({ [classes.active]: editorState?.isWarning })}
> >
<IconAlertTriangleFilled size={18} /> <IconAlertTriangleFilled
size={18}
color="var(--mantine-color-orange-5)"
/>
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -165,9 +194,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("danger")} onClick={() => setCalloutType("danger")}
size="lg" size="lg"
aria-label={t("Danger")} aria-label={t("Danger")}
variant={editorState?.isDanger ? "light" : "default"} variant="subtle"
className={clsx({ [classes.active]: editorState?.isDanger })}
> >
<IconCircleXFilled size={18} /> <IconCircleXFilled size={18} color="var(--mantine-color-red-5)" />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -178,11 +208,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
icon={currentIcon || <IconMoodSmile size={18} />} icon={currentIcon || <IconMoodSmile size={18} />}
actionIconProps={{ actionIconProps={{
size: "lg", size: "lg",
variant: "default", variant: "subtle",
c: undefined,
}} }}
/> />
</ActionIcon.Group> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
); );
} }
@@ -4,6 +4,7 @@ import {
IconCircleCheckFilled, IconCircleCheckFilled,
IconCircleXFilled, IconCircleXFilled,
IconInfoCircleFilled, IconInfoCircleFilled,
IconNotes,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Alert } from "@mantine/core"; import { Alert } from "@mantine/core";
import classes from "./callout.module.css"; import classes from "./callout.module.css";
@@ -22,6 +23,7 @@ export default function CalloutView(props: NodeViewProps) {
icon={getCalloutIcon(type, icon)} icon={getCalloutIcon(type, icon)}
p="xs" p="xs"
classNames={{ classNames={{
root: classes.root,
message: classes.message, message: classes.message,
icon: classes.icon, icon: classes.icon,
}} }}
@@ -34,12 +36,14 @@ export default function CalloutView(props: NodeViewProps) {
function getCalloutIcon(type: CalloutType, customIcon?: string) { function getCalloutIcon(type: CalloutType, customIcon?: string) {
if (customIcon && customIcon.trim() !== "") { if (customIcon && customIcon.trim() !== "") {
return <span style={{ fontSize: '18px' }}>{customIcon}</span>; return <span style={{ fontSize: "18px" }}>{customIcon}</span>;
} }
switch (type) { switch (type) {
case "info": case "info":
return <IconInfoCircleFilled />; return <IconInfoCircleFilled />;
case "note":
return <IconNotes />;
case "success": case "success":
return <IconCircleCheckFilled />; return <IconCircleCheckFilled />;
case "warning": case "warning":
@@ -55,6 +59,8 @@ function getCalloutColor(type: CalloutType) {
switch (type) { switch (type) {
case "info": case "info":
return "blue"; return "blue";
case "note":
return "grape";
case "success": case "success":
return "green"; return "green";
case "warning": case "warning":
@@ -1,9 +1,13 @@
.root {
overflow: visible;
}
.icon { .icon {
font-size: 24px; font-size: 24px;
line-height: 1; line-height: 1;
width: 20px; width: 20px;
height: 20px; height: 20px;
margin-inline-end: var(--mantine-spacing-md); margin-inline-end: var(--mantine-spacing-xs);
margin-top: 4px; margin-top: 4px;
cursor: pointer; cursor: pointer;
} }
@@ -11,18 +15,8 @@
.message { .message {
font-size: var(--mantine-font-size-md); font-size: var(--mantine-font-size-md);
color: var(--mantine-color-default-color); color: var(--mantine-color-default-color);
overflow: visible;
white-space: nowrap; text-overflow: unset;
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
/*
@mixin where-light {
color: var(--mantine-color-default-color);
}
@mixin where-dark {
color: var(--mantine-color-default-color);
}
*/
@@ -0,0 +1,361 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback, useRef, useState } from "react";
import { DOMSerializer, Node as PMNode } from "@tiptap/pm/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,
IconCopy,
IconTrash,
} from "@tabler/icons-react";
import { isTextSelected } from "@docmost/editor-ext";
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 [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
const nodesWithMenus = [
"callout",
"image",
"video",
"drawio",
"excalidraw",
"table",
];
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) return false;
if (!editor.isActive("columns")) return false;
if (isTextSelected(editor)) return false;
if (nodesWithMenus.some((name) => editor.isActive(name))) 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 handleCopy = useCallback(() => {
const { state } = editor;
const parent = findParentNode(
(node: PMNode) => node.type.name === "columns",
)(state.selection);
if (!parent) return;
const serializer = DOMSerializer.fromSchema(state.schema);
const dom = serializer.serializeNode(parent.node);
const wrapper = document.createElement("div");
wrapper.appendChild(dom);
const onSuccess = () => {
clearTimeout(copyTimerRef.current);
setCopied(true);
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
};
if (navigator.clipboard?.write) {
navigator.clipboard
.write([
new ClipboardItem({
"text/html": new Blob([wrapper.innerHTML], { type: "text/html" }),
"text/plain": new Blob([parent.node.textContent], {
type: "text/plain",
}),
}),
])
.then(onSuccess)
.catch(execCommandFallback);
} else {
execCommandFallback();
}
function execCommandFallback() {
wrapper.style.position = "fixed";
wrapper.style.left = "-9999px";
document.body.appendChild(wrapper);
const range = document.createRange();
range.selectNodeContents(wrapper);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand("copy");
sel?.removeAllRanges();
document.body.removeChild(wrapper);
editor.view.focus();
onSuccess();
}
}, [editor]);
const handleDelete = useCallback(() => {
const parent = findParentNode(
(node: PMNode) => node.type.name === "columns",
)(editor.state.selection);
if (!parent) return;
editor.chain().focus().setNodeSelection(parent.pos).deleteSelection().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 className={classes.divider} />
<Tooltip
position="top"
label={copied ? t("Copied") : t("Copy")}
withinPortal={false}
>
<ActionIcon
onClick={handleCopy}
size="lg"
aria-label={t("Copy")}
variant="subtle"
>
{copied ? (
<IconCheck size={18} color="var(--mantine-color-green-6)" />
) : (
<IconCopy size={18} />
)}
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
);
}
export default ColumnsMenu;
@@ -0,0 +1,35 @@
import type { ResizableNodeViewDirection } from "@tiptap/core";
import classes from "./node-resize.module.css";
export function createResizeHandle(
direction: ResizableNodeViewDirection,
): HTMLElement {
const handle = document.createElement("div");
handle.dataset.resizeHandle = direction;
handle.style.position = "absolute";
handle.className = classes.handle;
if (direction === "left") {
handle.style.left = "-8px";
handle.style.top = "0";
handle.style.bottom = "0";
} else if (direction === "right") {
handle.style.right = "-8px";
handle.style.top = "0";
handle.style.bottom = "0";
}
const bar = document.createElement("div");
bar.className = classes.handleBar;
handle.appendChild(bar);
return handle;
}
export function buildResizeClasses(nodeClass: string) {
return {
container: `${classes.container} ${nodeClass}`,
wrapper: classes.wrapper,
resizing: classes.resizing,
};
}
@@ -0,0 +1,65 @@
.container {
display: flex;
}
.wrapper {
position: relative;
border-radius: 8px;
overflow: visible;
max-width: 100%;
}
.wrapper img,
.wrapper video {
height: auto !important;
}
.resizing {
user-select: none;
}
.handle {
position: absolute;
top: 0;
bottom: 0;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
}
.handle[data-resize-handle="left"] {
left: -8px;
}
.handle[data-resize-handle="right"] {
right: -8px;
}
.wrapper:hover .handle {
opacity: 1;
}
.resizing .handle {
opacity: 1;
}
.handleBar {
width: 4px;
height: 48px;
border-radius: 4px;
transition: background-color 0.15s ease;
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
}
.handle:hover .handleBar {
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
.resizing .handleBar {
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
@@ -0,0 +1,29 @@
.toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 8px;
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
box-shadow: 0 2px 12px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35));
}
.toolbar :global(.mantine-ActionIcon-root) {
--ai-color: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-4)) !important;
--ai-hover: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)) !important;
}
.toolbar .active {
--ai-color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)) !important;
--ai-hover: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)) !important;
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5));
}
.divider {
width: 1px;
height: 16px;
align-self: center;
margin: 0 2px;
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
}
@@ -1,24 +1,41 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react"; import { useCallback, useRef, useState } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; import { ActionIcon, Modal, Tooltip, useComputedColorScheme } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import clsx from "clsx";
import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconDownload,
IconEdit,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
import { uploadFile } from "@/features/page/services/page-service.ts";
import {
DrawIoEmbed,
DrawIoEmbedRef,
EventExit,
EventSave,
} from "react-drawio";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const { t } = useTranslation();
({ state }: ShouldShowProps) => { const [opened, { open, close }] = useDisclosure(false);
if (!state) { const [initialXML, setInitialXML] = useState<string>("");
return false; const drawioRef = useRef<DrawIoEmbedRef>(null);
} const computedColorScheme = useComputedColorScheme();
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
},
[editor],
);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -30,11 +47,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const drawioAttr = ctx.editor.getAttributes("drawio"); const drawioAttr = ctx.editor.getAttributes("drawio");
return { return {
isDrawio: ctx.editor.isActive("drawio"), isDrawio: ctx.editor.isActive("drawio"),
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null, isAlignLeft: ctx.editor.isActive("drawio", { align: "left" }),
isAlignCenter: ctx.editor.isActive("drawio", { align: "center" }),
isAlignRight: ctx.editor.isActive("drawio", { align: "right" }),
src: drawioAttr?.src || null,
attachmentId: drawioAttr?.attachmentId || null,
}; };
}, },
}); });
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
@@ -57,38 +89,218 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
}; };
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const alignLeft = useCallback(() => {
(value: number) => { editor
editor.commands.updateAttributes("drawio", { width: `${value}%` }); .chain()
.focus(undefined, { scrollIntoView: false })
.setDrawioAlign("left")
.run();
}, [editor]);
const alignCenter = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setDrawioAlign("center")
.run();
}, [editor]);
const alignRight = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setDrawioAlign("right")
.run();
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
}, [editorState?.src]);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
try {
const url = getFileUrl(editorState.src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
open();
}
}, [editorState?.src, open]);
const handleSave = useCallback(
async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
editor.commands.updateAttributes("drawio", {
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
close();
}, },
[editor], [editor, editorState?.attachmentId, close],
); );
return ( return (
<BaseBubbleMenu <>
editor={editor} <BaseBubbleMenu
pluginKey={`drawio-menu`} editor={editor}
updateDelay={0} pluginKey={`drawio-menu`}
getReferencedVirtualElement={getReferencedVirtualElement} updateDelay={0}
options={{ getReferencedVirtualElement={getReferencedVirtualElement}
placement: "top", options={{
offset: 8, placement: "top",
flip: false, offset: 8,
}} flip: false,
shouldShow={shouldShow}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}} }}
shouldShow={shouldShow}
> >
{editorState?.width && ( <div className={classes.toolbar}>
<NodeWidthResize onChange={onWidthChange} value={editorState.width} /> <Tooltip position="top" label={t("Align left")}>
)} <ActionIcon
</div> onClick={alignLeft}
</BaseBubbleMenu> size="lg"
aria-label={t("Align left")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Align center")}>
<ActionIcon
onClick={alignCenter}
size="lg"
aria-label={t("Align center")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Align right")}>
<ActionIcon
onClick={alignRight}
size="lg"
aria-label={t("Align right")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignRight })}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Edit")}>
<ActionIcon
onClick={handleOpen}
size="lg"
aria-label={t("Edit")}
variant="subtle"
>
<IconEdit size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")}>
<ActionIcon
onClick={handleDownload}
size="lg"
aria-label={t("Download")}
variant="subtle"
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
<Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body>
<div style={{ height: "100vh" }}>
<DrawIoEmbed
ref={drawioRef}
xml={initialXML}
baseUrl={getDrawioUrl()}
urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true,
libraries: true,
saveAndExit: true,
noSaveBtn: true,
}}
onSave={(data: EventSave) => {
if (data.parentEvent !== "save") {
return;
}
handleSave(data);
}}
onClose={(data: EventExit) => {
if (data.parentEvent) {
return;
}
close();
}}
/>
</div>
</Modal.Body>
</Modal.Content>
</Modal.Root>
</>
); );
} }
@@ -2,7 +2,6 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { import {
ActionIcon, ActionIcon,
Card, Card,
Image,
Modal, Modal,
Text, Text,
useComputedColorScheme, useComputedColorScheme,
@@ -10,7 +9,7 @@ import {
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts"; import { getDrawioUrl } from "@/lib/config.ts";
import { import {
DrawIoEmbed, DrawIoEmbed,
DrawIoEmbedRef, DrawIoEmbedRef,
@@ -26,7 +25,7 @@ import { useTranslation } from "react-i18next";
export default function DrawioView(props: NodeViewProps) { export default function DrawioView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { node, updateAttributes, editor, selected } = props; const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs; const { attachmentId } = node.attrs;
const drawioRef = useRef<DrawIoEmbedRef>(null); const drawioRef = useRef<DrawIoEmbedRef>(null);
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
@@ -36,33 +35,11 @@ export default function DrawioView(props: NodeViewProps) {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
} }
open();
try {
if (src) {
const url = getFileUrl(src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
}
} catch (err) {
console.error(err);
} finally {
open();
}
}; };
const handleSave = async (data: EventSave) => { const handleSave = async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml); const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg"; const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName); const drawioSVGFile = await svgStringToFile(svgString, fileName);
@@ -70,7 +47,6 @@ export default function DrawioView(props: NodeViewProps) {
const pageId = editor.storage?.pageId; const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; let attachment: IAttachment = null;
if (attachmentId) { if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else { } else {
@@ -106,14 +82,12 @@ export default function DrawioView(props: NodeViewProps) {
noSaveBtn: true, noSaveBtn: true,
}} }}
onSave={(data: EventSave) => { onSave={(data: EventSave) => {
// If the save is triggered by another event, then do nothing
if (data.parentEvent !== "save") { if (data.parentEvent !== "save") {
return; return;
} }
handleSave(data); handleSave(data);
}} }}
onClose={(data: EventExit) => { onClose={(data: EventExit) => {
// If the exit is triggered by another event, then do nothing
if (data.parentEvent) { if (data.parentEvent) {
return; return;
} }
@@ -125,62 +99,28 @@ export default function DrawioView(props: NodeViewProps) {
</Modal.Content> </Modal.Content>
</Modal.Root> </Modal.Root>
{src ? ( <Card
<div style={{ position: "relative" }}> radius="md"
<Image onClick={(e) => e.detail === 2 && handleOpen()}
onClick={(e) => e.detail === 2 && handleOpen()} p="xs"
radius="md" style={{
fit="contain" display: "flex",
w={width} justifyContent: "center",
src={getFileUrl(src)} alignItems: "center",
alt={title} }}
className={clsx( withBorder
selected ? "ProseMirror-selectednode" : "", className={clsx(selected ? "ProseMirror-selectednode" : "")}
"alignCenter", >
)} <div style={{ display: "flex", alignItems: "center" }}>
/> <ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
{selected && editor.isEditable && ( <Text component="span" size="lg" c="dimmed">
<ActionIcon {t("Double-click to edit Draw.io diagram")}
onClick={handleOpen} </Text>
variant="default"
color="gray"
mx="xs"
className="print-hide"
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<IconEdit size={18} />
</ActionIcon>
)}
</div> </div>
) : ( </Card>
<Card
radius="md"
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
withBorder
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
{t("Double-click to edit Draw.io diagram")}
</Text>
</div>
</Card>
)}
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
@@ -1,26 +1,57 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react"; import { lazy, Suspense, useCallback, useState } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; import {
ActionIcon,
Button,
Group,
Tooltip,
useComputedColorScheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import clsx from "clsx";
import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconDownload,
IconEdit,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import classes from "../common/toolbar-menu.module.css";
const ExcalidrawComponent = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({
default: module.Excalidraw,
})),
);
export function ExcalidrawMenu({ editor }: EditorMenuProps) { export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback( const { t } = useTranslation();
({ state }: ShouldShowProps) => { const [opened, { open, close }] = useDisclosure(false);
if (!state) { const [excalidrawAPI, setExcalidrawAPI] =
return false; useState<ExcalidrawImperativeAPI>(null);
} useHandleLibrary({
excalidrawAPI,
return ( adapter: localStorageLibraryAdapter,
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src });
); const [excalidrawData, setExcalidrawData] = useState<any>(null);
}, const computedColorScheme = useComputedColorScheme();
[editor],
);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -32,11 +63,29 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const excalidrawAttr = ctx.editor.getAttributes("excalidraw"); const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
return { return {
isExcalidraw: ctx.editor.isActive("excalidraw"), isExcalidraw: ctx.editor.isActive("excalidraw"),
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null, isAlignLeft: ctx.editor.isActive("excalidraw", { align: "left" }),
isAlignCenter: ctx.editor.isActive("excalidraw", { align: "center" }),
isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }),
src: excalidrawAttr?.src || null,
attachmentId: excalidrawAttr?.attachmentId || null,
}; };
}, },
}); });
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return (
editor.isActive("excalidraw") &&
editor.getAttributes("excalidraw")?.src
);
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => { const getReferencedVirtualElement = useCallback(() => {
if (!editor) return; if (!editor) return;
const { selection } = editor.state; const { selection } = editor.state;
@@ -59,38 +108,248 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}; };
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const alignLeft = useCallback(() => {
(value: number) => { editor
editor.commands.updateAttributes("excalidraw", { width: `${value}%` }); .chain()
}, .focus(undefined, { scrollIntoView: false })
[editor], .setExcalidrawAlign("left")
); .run();
}, [editor]);
const alignCenter = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setExcalidrawAlign("center")
.run();
}, [editor]);
const alignRight = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setExcalidrawAlign("right")
.run();
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
}, [editorState?.src]);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
try {
const url = getFileUrl(editorState.src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const { loadFromBlob } = await import("@excalidraw/excalidraw");
const data = await loadFromBlob(await request.blob(), null, null);
setExcalidrawData(data);
} catch (err) {
console.error(err);
} finally {
open();
}
}, [editorState?.src, open]);
const handleSave = useCallback(async () => {
if (!excalidrawAPI) {
return;
}
const { exportToSvg } = await import("@excalidraw/excalidraw");
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
editor.commands.updateAttributes("excalidraw", {
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
close();
}, [editor, excalidrawAPI, editorState?.attachmentId, close]);
return ( return (
<BaseBubbleMenu <>
editor={editor} <BaseBubbleMenu
pluginKey={`excalidraw-menu`} editor={editor}
updateDelay={0} pluginKey={`excalidraw-menu`}
getReferencedVirtualElement={getReferencedVirtualElement} updateDelay={0}
options={{ getReferencedVirtualElement={getReferencedVirtualElement}
placement: "top", options={{
offset: 8, placement: "top",
flip: false, offset: 8,
}} flip: false,
shouldShow={shouldShow} }}
> shouldShow={shouldShow}
<div >
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")}>
<ActionIcon
onClick={alignLeft}
size="lg"
aria-label={t("Align left")}
variant="subtle"
className={clsx({
[classes.active]: editorState?.isAlignLeft,
})}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Align center")}>
<ActionIcon
onClick={alignCenter}
size="lg"
aria-label={t("Align center")}
variant="subtle"
className={clsx({
[classes.active]: editorState?.isAlignCenter,
})}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Align right")}>
<ActionIcon
onClick={alignRight}
size="lg"
aria-label={t("Align right")}
variant="subtle"
className={clsx({
[classes.active]: editorState?.isAlignRight,
})}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Edit")}>
<ActionIcon
onClick={handleOpen}
size="lg"
aria-label={t("Edit")}
variant="subtle"
>
<IconEdit size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Download")}>
<ActionIcon
onClick={handleDownload}
size="lg"
aria-label={t("Download")}
variant="subtle"
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
<ReactClearModal
style={{ style={{
display: "flex", backgroundColor: "rgba(0, 0, 0, 0.5)",
flexDirection: "column", padding: 0,
alignItems: "center", zIndex: 200,
}}
isOpen={opened}
onRequestClose={close}
disableCloseOnBgClick={true}
contentProps={{
style: {
padding: 0,
width: "90vw",
},
}} }}
> >
{editorState?.width && ( <Group
<NodeWidthResize onChange={onWidthChange} value={editorState.width} /> justify="flex-end"
)} wrap="nowrap"
</div> bg="var(--mantine-color-body)"
</BaseBubbleMenu> p="xs"
>
<Button onClick={handleSave} size={"compact-sm"}>
{t("Save & Exit")}
</Button>
<Button onClick={close} color="red" size={"compact-sm"}>
{t("Exit")}
</Button>
</Group>
<div style={{ height: "90vh" }}>
<Suspense fallback={null}>
<ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)}
initialData={{
...excalidrawData,
scrollToContent: true,
}}
theme={computedColorScheme}
/>
</Suspense>
</div>
</ReactClearModal>
</>
); );
} }
@@ -4,28 +4,24 @@ import {
Button, Button,
Card, Card,
Group, Group,
Image,
Text, Text,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { useState } from "react"; import { lazy, Suspense, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib"; import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import "@excalidraw/excalidraw/index.css"; import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal"; import ReactClearModal from "react-clear-modal";
import clsx from "clsx"; import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react"; import { IconEdit } from "@tabler/icons-react";
import { lazy } from "react";
import { Suspense } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw"; import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
const Excalidraw = lazy(() => const ExcalidrawComponent = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({ import("@excalidraw/excalidraw").then((module) => ({
default: module.Excalidraw, default: module.Excalidraw,
})), })),
@@ -34,7 +30,7 @@ const Excalidraw = lazy(() =>
export default function ExcalidrawView(props: NodeViewProps) { export default function ExcalidrawView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { node, updateAttributes, editor, selected } = props; const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs; const { attachmentId } = node.attrs;
const [excalidrawAPI, setExcalidrawAPI] = const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null); useState<ExcalidrawImperativeAPI>(null);
@@ -50,25 +46,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
} }
open();
try {
if (src) {
const url = getFileUrl(src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const { loadFromBlob } = await import("@excalidraw/excalidraw");
const data = await loadFromBlob(await request.blob(), null, null);
setExcalidrawData(data);
}
} catch (err) {
console.error(err);
} finally {
open();
}
}; };
const handleSave = async () => { const handleSave = async () => {
@@ -151,7 +129,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
</Group> </Group>
<div style={{ height: "90vh" }}> <div style={{ height: "90vh" }}>
<Suspense fallback={null}> <Suspense fallback={null}>
<Excalidraw <ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)} excalidrawAPI={(api) => setExcalidrawAPI(api)}
initialData={{ initialData={{
...excalidrawData, ...excalidrawData,
@@ -163,62 +141,28 @@ export default function ExcalidrawView(props: NodeViewProps) {
</div> </div>
</ReactClearModal> </ReactClearModal>
{src ? ( <Card
<div style={{ position: "relative" }}> radius="md"
<Image onClick={(e) => e.detail === 2 && handleOpen()}
onClick={(e) => e.detail === 2 && handleOpen()} p="xs"
radius="md" style={{
fit="contain" display: "flex",
w={width} justifyContent: "center",
src={getFileUrl(src)} alignItems: "center",
alt={title} }}
className={clsx( withBorder
selected ? "ProseMirror-selectednode" : "", className={clsx(selected ? "ProseMirror-selectednode" : "")}
"alignCenter", >
)} <div style={{ display: "flex", alignItems: "center" }}>
/> <ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
{selected && editor.isEditable && ( <Text component="span" size="lg" c="dimmed">
<ActionIcon {t("Double-click to edit Excalidraw diagram")}
onClick={handleOpen} </Text>
variant="default"
color="gray"
mx="xs"
className="print-hide"
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<IconEdit size={18} />
</ActionIcon>
)}
</div> </div>
) : ( </Card>
<Card
radius="md"
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
withBorder
className={clsx(selected ? "ProseMirror-selectednode" : "")}
>
<div style={{ display: "flex", alignItems: "center" }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
{t("Double-click to edit Excalidraw diagram")}
</Text>
</div>
</Card>
)}
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
@@ -1,22 +1,29 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback } from "react"; import React, { useCallback, useRef } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import clsx from "clsx";
import { import {
IconLayoutAlignCenter, IconLayoutAlignCenter,
IconLayoutAlignLeft, IconLayoutAlignLeft,
IconLayoutAlignRight, IconLayoutAlignRight,
IconDownload,
IconRefresh,
IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) { export function ImageMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -32,7 +39,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignLeft: ctx.editor.isActive("image", { align: "left" }), isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }), isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }), isAlignRight: ctx.editor.isActive("image", { align: "right" }),
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null, src: imageAttrs?.src || null,
}; };
}, },
}); });
@@ -94,17 +101,40 @@ export function ImageMenu({ editor }: EditorMenuProps) {
.run(); .run();
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const handleDownload = useCallback(() => {
(value: number) => { if (!editorState?.src) return;
editor const url = getFileUrl(editorState.src);
.chain() const a = document.createElement("a");
.focus(undefined, { scrollIntoView: false }) a.href = url;
.setImageWidth(value) a.download = "";
.run(); a.click();
}, [editorState?.src]);
const handleReplace = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// @ts-ignore
const pageId = editor.storage?.pageId;
if (pageId) {
const pos = editor.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
}
// Reset so the same file can be selected again
e.target.value = "";
}, },
[editor], [editor],
); );
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
@@ -118,13 +148,14 @@ export function ImageMenu({ editor }: EditorMenuProps) {
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group className="actionIconGroup"> <div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")}> <Tooltip position="top" label={t("Align left")}>
<ActionIcon <ActionIcon
onClick={alignImageLeft} onClick={alignImageLeft}
size="lg" size="lg"
aria-label={t("Align left")} aria-label={t("Align left")}
variant={editorState?.isAlignLeft ? "light" : "default"} variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
> >
<IconLayoutAlignLeft size={18} /> <IconLayoutAlignLeft size={18} />
</ActionIcon> </ActionIcon>
@@ -135,7 +166,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageCenter} onClick={alignImageCenter}
size="lg" size="lg"
aria-label={t("Align center")} aria-label={t("Align center")}
variant={editorState?.isAlignCenter ? "light" : "default"} variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
> >
<IconLayoutAlignCenter size={18} /> <IconLayoutAlignCenter size={18} />
</ActionIcon> </ActionIcon>
@@ -146,16 +178,56 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageRight} onClick={alignImageRight}
size="lg" size="lg"
aria-label={t("Align right")} aria-label={t("Align right")}
variant={editorState?.isAlignRight ? "light" : "default"} variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignRight })}
> >
<IconLayoutAlignRight size={18} /> <IconLayoutAlignRight size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group>
{editorState?.width && ( <div className={classes.divider} />
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)} <Tooltip position="top" label={t("Download")}>
<ActionIcon
onClick={handleDownload}
size="lg"
aria-label={t("Download")}
variant="subtle"
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Replace image")}>
<ActionIcon
onClick={handleReplace}
size="lg"
aria-label={t("Replace image")}
variant="subtle"
>
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={handleFileChange}
/>
</BaseBubbleMenu> </BaseBubbleMenu>
); );
} }
@@ -0,0 +1,7 @@
import {
createResizeHandle,
buildResizeClasses,
} from "../common/node-resize-handles";
export const createImageHandle = createResizeHandle;
export const imageResizeClasses = buildResizeClasses("node-image");
@@ -0,0 +1,64 @@
.container {
display: flex;
}
.wrapper {
position: relative;
border-radius: 8px;
overflow: visible;
max-width: 100%;
}
.wrapper img {
height: auto !important;
}
.resizing {
user-select: none;
}
.handle {
position: absolute;
top: 0;
bottom: 0;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
}
.handle[data-resize-handle="left"] {
left: -8px;
}
.handle[data-resize-handle="right"] {
right: -8px;
}
.wrapper:hover .handle {
opacity: 1;
}
.resizing .handle {
opacity: 1;
}
.handleBar {
width: 4px;
height: 48px;
border-radius: 4px;
transition: background-color 0.15s ease;
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
}
.handle:hover .handleBar {
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
.resizing .handleBar {
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
}
@@ -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",
@@ -95,7 +95,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
<Popover.Target> <Popover.Target>
<Tooltip label={t("Background color")} withArrow> <Tooltip label={t("Background color")} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Background color")} aria-label={t("Background color")}
onClick={() => setOpened(!opened)} onClick={() => setOpened(!opened)}
@@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color"; import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment"; import { TableTextAlignment } from "./table-text-alignment";
import { BubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu } from "@tiptap/react/menus";
import classes from "../common/toolbar-menu.module.css";
export const TableCellMenu = React.memo( export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => { ({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@@ -69,14 +70,16 @@ export const TableCellMenu = React.memo(
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group> <div className={classes.toolbar}>
<TableBackgroundColor editor={editor} /> <TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} /> <TableTextAlignment editor={editor} />
<div className={classes.divider} />
<Tooltip position="top" label={t("Merge cells")}> <Tooltip position="top" label={t("Merge cells")}>
<ActionIcon <ActionIcon
onClick={mergeCells} onClick={mergeCells}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Merge cells")} aria-label={t("Merge cells")}
> >
@@ -87,7 +90,7 @@ export const TableCellMenu = React.memo(
<Tooltip position="top" label={t("Split cell")}> <Tooltip position="top" label={t("Split cell")}>
<ActionIcon <ActionIcon
onClick={splitCell} onClick={splitCell}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Split cell")} aria-label={t("Split cell")}
> >
@@ -95,10 +98,12 @@ export const TableCellMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Delete column")}> <Tooltip position="top" label={t("Delete column")}>
<ActionIcon <ActionIcon
onClick={deleteColumn} onClick={deleteColumn}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Delete column")} aria-label={t("Delete column")}
> >
@@ -109,7 +114,7 @@ export const TableCellMenu = React.memo(
<Tooltip position="top" label={t("Delete row")}> <Tooltip position="top" label={t("Delete row")}>
<ActionIcon <ActionIcon
onClick={deleteRow} onClick={deleteRow}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Delete row")} aria-label={t("Delete row")}
> >
@@ -117,17 +122,19 @@ export const TableCellMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Toggle header cell")}> <Tooltip position="top" label={t("Toggle header cell")}>
<ActionIcon <ActionIcon
onClick={toggleHeaderCell} onClick={toggleHeaderCell}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Toggle header cell")} aria-label={t("Toggle header cell")}
> >
<IconTableRow size={18} /> <IconTableRow size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </div>
</BubbleMenu> </BubbleMenu>
); );
} }
@@ -18,8 +18,9 @@ import {
IconTrashX, IconTrashX,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { BubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu } from "@tiptap/react/menus";
import { isCellSelection } from "@docmost/editor-ext"; import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
export const TableMenu = React.memo( export const TableMenu = React.memo(
({ editor }: EditorMenuProps): JSX.Element => { ({ editor }: EditorMenuProps): JSX.Element => {
@@ -30,6 +31,7 @@ export const TableMenu = React.memo(
return false; return false;
} }
if (isTextSelected(editor)) return false;
return editor.isActive("table") && !isCellSelection(state.selection); return editor.isActive("table") && !isCellSelection(state.selection);
}, },
[editor] [editor]
@@ -118,11 +120,11 @@ export const TableMenu = React.memo(
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group> <div className={classes.toolbar}>
<Tooltip position="top" label={t("Add left column")}> <Tooltip position="top" label={t("Add left column")}>
<ActionIcon <ActionIcon
onClick={addColumnLeft} onClick={addColumnLeft}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Add left column")} aria-label={t("Add left column")}
> >
@@ -133,7 +135,7 @@ export const TableMenu = React.memo(
<Tooltip position="top" label={t("Add right column")}> <Tooltip position="top" label={t("Add right column")}>
<ActionIcon <ActionIcon
onClick={addColumnRight} onClick={addColumnRight}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Add right column")} aria-label={t("Add right column")}
> >
@@ -144,7 +146,7 @@ export const TableMenu = React.memo(
<Tooltip position="top" label={t("Delete column")}> <Tooltip position="top" label={t("Delete column")}>
<ActionIcon <ActionIcon
onClick={deleteColumn} onClick={deleteColumn}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Delete column")} aria-label={t("Delete column")}
> >
@@ -152,10 +154,12 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Add row above")}> <Tooltip position="top" label={t("Add row above")}>
<ActionIcon <ActionIcon
onClick={addRowAbove} onClick={addRowAbove}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Add row above")} aria-label={t("Add row above")}
> >
@@ -166,7 +170,7 @@ export const TableMenu = React.memo(
<Tooltip position="top" label={t("Add row below")}> <Tooltip position="top" label={t("Add row below")}>
<ActionIcon <ActionIcon
onClick={addRowBelow} onClick={addRowBelow}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Add row below")} aria-label={t("Add row below")}
> >
@@ -177,7 +181,7 @@ export const TableMenu = React.memo(
<Tooltip position="top" label={t("Delete row")}> <Tooltip position="top" label={t("Delete row")}>
<ActionIcon <ActionIcon
onClick={deleteRow} onClick={deleteRow}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Delete row")} aria-label={t("Delete row")}
> >
@@ -185,10 +189,12 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Toggle header row")}> <Tooltip position="top" label={t("Toggle header row")}>
<ActionIcon <ActionIcon
onClick={toggleHeaderRow} onClick={toggleHeaderRow}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Toggle header row")} aria-label={t("Toggle header row")}
> >
@@ -199,7 +205,7 @@ export const TableMenu = React.memo(
<Tooltip position="top" label={t("Toggle header column")}> <Tooltip position="top" label={t("Toggle header column")}>
<ActionIcon <ActionIcon
onClick={toggleHeaderColumn} onClick={toggleHeaderColumn}
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Toggle header column")} aria-label={t("Toggle header column")}
> >
@@ -207,18 +213,19 @@ export const TableMenu = React.memo(
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<div className={classes.divider} />
<Tooltip position="top" label={t("Delete table")}> <Tooltip position="top" label={t("Delete table")}>
<ActionIcon <ActionIcon
onClick={deleteTable} onClick={deleteTable}
variant="default" variant="subtle"
size="lg" size="lg"
color="red"
aria-label={t("Delete table")} aria-label={t("Delete table")}
> >
<IconTrashX size={18} /> <IconTrashX size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group> </div>
</BubbleMenu> </BubbleMenu>
); );
} }
@@ -88,7 +88,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text alignment")} withArrow> <Tooltip label={t("Text alignment")} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="subtle"
size="lg" size="lg"
aria-label={t("Text alignment")} aria-label={t("Text alignment")}
onClick={() => setOpened(!opened)} onClick={() => setOpened(!opened)}
@@ -1,19 +1,23 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import React, { useCallback } from "react"; import { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import clsx from "clsx";
import { import {
IconLayoutAlignCenter, IconLayoutAlignCenter,
IconLayoutAlignLeft, IconLayoutAlignLeft,
IconLayoutAlignRight, IconLayoutAlignRight,
IconDownload,
IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import classes from "../common/toolbar-menu.module.css";
export function VideoMenu({ editor }: EditorMenuProps) { export function VideoMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,7 +36,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
isAlignLeft: ctx.editor.isActive("video", { align: "left" }), isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
isAlignCenter: ctx.editor.isActive("video", { align: "center" }), isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
isAlignRight: ctx.editor.isActive("video", { align: "right" }), isAlignRight: ctx.editor.isActive("video", { align: "right" }),
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null, src: videoAttrs?.src || null,
}; };
}, },
}); });
@@ -70,7 +74,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
}; };
}, [editor]); }, [editor]);
const alignVideoLeft = useCallback(() => { const alignLeft = useCallback(() => {
editor editor
.chain() .chain()
.focus(undefined, { scrollIntoView: false }) .focus(undefined, { scrollIntoView: false })
@@ -78,7 +82,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
.run(); .run();
}, [editor]); }, [editor]);
const alignVideoCenter = useCallback(() => { const alignCenter = useCallback(() => {
editor editor
.chain() .chain()
.focus(undefined, { scrollIntoView: false }) .focus(undefined, { scrollIntoView: false })
@@ -86,7 +90,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
.run(); .run();
}, [editor]); }, [editor]);
const alignVideoRight = useCallback(() => { const alignRight = useCallback(() => {
editor editor
.chain() .chain()
.focus(undefined, { scrollIntoView: false }) .focus(undefined, { scrollIntoView: false })
@@ -94,16 +98,18 @@ export function VideoMenu({ editor }: EditorMenuProps) {
.run(); .run();
}, [editor]); }, [editor]);
const onWidthChange = useCallback( const handleDownload = useCallback(() => {
(value: number) => { if (!editorState?.src) return;
editor const url = getFileUrl(editorState.src);
.chain() const a = document.createElement("a");
.focus(undefined, { scrollIntoView: false }) a.href = url;
.setVideoWidth(value) a.download = "";
.run(); a.click();
}, }, [editorState?.src]);
[editor],
); const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
return ( return (
<BaseBubbleMenu <BaseBubbleMenu
@@ -118,13 +124,14 @@ export function VideoMenu({ editor }: EditorMenuProps) {
}} }}
shouldShow={shouldShow} shouldShow={shouldShow}
> >
<ActionIcon.Group className="actionIconGroup"> <div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")}> <Tooltip position="top" label={t("Align left")}>
<ActionIcon <ActionIcon
onClick={alignVideoLeft} onClick={alignLeft}
size="lg" size="lg"
aria-label={t("Align left")} aria-label={t("Align left")}
variant={editorState?.isAlignLeft ? "light" : "default"} variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
> >
<IconLayoutAlignLeft size={18} /> <IconLayoutAlignLeft size={18} />
</ActionIcon> </ActionIcon>
@@ -132,10 +139,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<Tooltip position="top" label={t("Align center")}> <Tooltip position="top" label={t("Align center")}>
<ActionIcon <ActionIcon
onClick={alignVideoCenter} onClick={alignCenter}
size="lg" size="lg"
aria-label={t("Align center")} aria-label={t("Align center")}
variant={editorState?.isAlignCenter ? "light" : "default"} variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
> >
<IconLayoutAlignCenter size={18} /> <IconLayoutAlignCenter size={18} />
</ActionIcon> </ActionIcon>
@@ -143,19 +151,40 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<Tooltip position="top" label={t("Align right")}> <Tooltip position="top" label={t("Align right")}>
<ActionIcon <ActionIcon
onClick={alignVideoRight} onClick={alignRight}
size="lg" size="lg"
aria-label={t("Align right")} aria-label={t("Align right")}
variant={editorState?.isAlignRight ? "light" : "default"} variant="subtle"
className={clsx({ [classes.active]: editorState?.isAlignRight })}
> >
<IconLayoutAlignRight size={18} /> <IconLayoutAlignRight size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</ActionIcon.Group>
{editorState?.width && ( <div className={classes.divider} />
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)} <Tooltip position="top" label={t("Download")}>
<ActionIcon
onClick={handleDownload}
size="lg"
aria-label={t("Download")}
variant="subtle"
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu> </BaseBubbleMenu>
); );
} }
@@ -1,4 +1,6 @@
import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list"; import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount } from "@tiptap/extensions"; import { Placeholder, CharacterCount } from "@tiptap/extensions";
@@ -43,6 +45,8 @@ import {
Highlight, Highlight,
UniqueID, UniqueID,
SharedStorage, SharedStorage,
Columns,
Column,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -52,6 +56,14 @@ import { IUser } from "@/features/user/types/user.types.ts";
import MathInlineView from "@/features/editor/components/math/math-inline.tsx"; import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.tsx"; import MathBlockView from "@/features/editor/components/math/math-block.tsx";
import ImageView from "@/features/editor/components/image/image-view.tsx"; import ImageView from "@/features/editor/components/image/image-view.tsx";
import {
createImageHandle,
imageResizeClasses,
} from "@/features/editor/components/image/image-resize-handles.ts";
import {
createResizeHandle,
buildResizeClasses,
} from "@/features/editor/components/common/node-resize-handles.ts";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx"; import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import VideoView from "@/features/editor/components/video/video-view.tsx"; import VideoView from "@/features/editor/components/video/video-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx"; import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
@@ -91,6 +103,7 @@ lowlight.register("fortran", fortran);
lowlight.register("haskell", haskell); lowlight.register("haskell", haskell);
lowlight.register("scala", scala); lowlight.register("scala", scala);
// @ts-ignore
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
heading: false, heading: false,
@@ -102,10 +115,24 @@ export const mainExtensions = [
color: "#70CFF8", color: "#70CFF8",
}, },
codeBlock: false, codeBlock: false,
code: { code: false,
HTMLAttributes: { }),
spellcheck: false, // Override TipTap's Code extension to fix the inline code input rule.
}, // The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
// to delete it. Using a lookbehind avoids including it in the match.
Code.configure({
HTMLAttributes: {
spellcheck: false,
},
}).extend({
addInputRules() {
return [
markInputRule({
find: /(?:^|(?<=[^`]))`([^`]+)`(?!`)$/,
type: this.type,
}),
];
}, },
}), }),
SharedStorage, SharedStorage,
@@ -115,7 +142,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 });
} }
@@ -123,6 +150,17 @@ 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);
const parentName = $pos.parent.type.name;
if (
parentName === "column" ||
parentName === "tableCell" ||
parentName === "tableHeader" ||
parentName === "callout" ||
parentName === "blockquote"
) {
return i18n.t("Write...");
}
return i18n.t('Write anything. Enter "/" for commands'); return i18n.t('Write anything. Enter "/" for commands');
} }
}, },
@@ -200,9 +238,29 @@ export const mainExtensions = [
TiptapImage.configure({ TiptapImage.configure({
view: ImageView, view: ImageView,
allowBase64: false, allowBase64: false,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createImageHandle,
className: imageResizeClasses,
},
}), }),
TiptapVideo.configure({ TiptapVideo.configure({
view: VideoView, view: VideoView,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
className: buildResizeClasses("node-video"),
},
}), }),
Callout.configure({ Callout.configure({
view: CalloutView, view: CalloutView,
@@ -221,9 +279,29 @@ export const mainExtensions = [
}), }),
Drawio.configure({ Drawio.configure({
view: DrawioView, view: DrawioView,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
className: buildResizeClasses("node-drawio"),
},
}), }),
Excalidraw.configure({ Excalidraw.configure({
view: ExcalidrawView, view: ExcalidrawView,
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
className: buildResizeClasses("node-excalidraw"),
},
}), }),
Embed.configure({ Embed.configure({
view: EmbedView, view: EmbedView,
@@ -253,6 +331,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,124 @@
div[data-type="columns"] {
display: flex;
margin: 0.75rem 0;
padding: 0.5em 0;
}
div[data-type="columns"] > div[data-type="column"] {
flex: 1;
min-width: 0;
padding-right: 1rem;
}
div[data-type="columns"] > div[data-type="column"]:last-child {
padding-right: 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"],
div[data-type="columns"].has-focus
> 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: 680px) {
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"] {
flex-direction: row !important;
}
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
border-left: none;
padding-left: 1rem;
}
div[data-type="columns"][data-width-mode="wide"] {
margin-left: 0;
margin-right: 0;
width: 100%;
}
}
+11 -14
View File
@@ -82,13 +82,9 @@
} }
blockquote { blockquote {
padding-left: 25px; padding-left: 1rem;
padding-right: 25px; border-left: 3px solid
border-left: 2px solid var(--mantine-color-gray-6); light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-8)
);
margin: 0; margin: 0;
} }
@@ -126,13 +122,14 @@
margin-bottom: 0; margin-bottom: 0;
} }
&.node-callout { }
div[style*="white-space: inherit;"] {
> :first-child { .react-renderer.node-callout div[style*="white-space: inherit;"] > :first-child {
margin: 0; margin-top: 0;
} }
}
} .react-renderer.node-callout + .react-renderer.node-callout {
margin-top: 0.75em;
} }
.selection { .selection {
@@ -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";
+3 -3
View File
@@ -42,9 +42,9 @@ if (isCloud() && isPostHogEnabled) {
}); });
} }
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement, const container = document.getElementById("root") as HTMLElement;
); const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
root.render( root.render(
<BrowserRouter> <BrowserRouter>
+9 -9
View File
@@ -33,9 +33,9 @@
"@ai-sdk/google": "^3.0.29", "@ai-sdk/google": "^3.0.29",
"@ai-sdk/openai": "^3.0.29", "@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30", "@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-s3": "3.982.0", "@aws-sdk/client-s3": "3.998.0",
"@aws-sdk/lib-storage": "3.982.0", "@aws-sdk/lib-storage": "3.998.0",
"@aws-sdk/s3-request-presigner": "3.982.0", "@aws-sdk/s3-request-presigner": "3.998.0",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
@@ -43,18 +43,18 @@
"@langchain/textsplitters": "1.0.1", "@langchain/textsplitters": "1.0.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.11", "@nestjs/common": "^11.1.14",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.13", "@nestjs/core": "^11.1.14",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0", "@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.13", "@nestjs/platform-fastify": "^11.1.14",
"@nestjs/platform-socket.io": "^11.1.13", "@nestjs/platform-socket.io": "^11.1.14",
"@nestjs/schedule": "^6.1.0", "@nestjs/schedule": "^6.1.0",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.13", "@nestjs/websockets": "^11.1.14",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.7", "@react-email/components": "1.0.7",
"@react-email/render": "2.0.4", "@react-email/render": "2.0.4",
@@ -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) {
@@ -50,6 +50,7 @@ export async function formatImportHtml(opts: {
} }
notionFormatter($, $root); notionFormatter($, $root);
xwikiFormatter($, $root);
defaultHtmlFormatter($, $root); defaultHtmlFormatter($, $root);
const backlinks = await rewriteInternalLinksToMentionHtml( const backlinks = await rewriteInternalLinksToMentionHtml(
@@ -69,6 +70,14 @@ export async function formatImportHtml(opts: {
}; };
} }
export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
const $content = $root.find('#xwikicontent');
if ($content.length) {
$root.children().remove();
$root.append($content.contents());
}
}
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) { export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
$root.find('a[href]').each((_, el) => { $root.find('a[href]').each((_, el) => {
const $el = $(el); const $el = $(el);
+7 -5
View File
@@ -79,13 +79,13 @@
"yjs": "^13.6.29" "yjs": "^13.6.29"
}, },
"devDependencies": { "devDependencies": {
"@nx/js": "22.5.0", "@nx/js": "22.5.2",
"@types/bytes": "^3.1.5", "@types/bytes": "^3.1.5",
"@types/turndown": "^5.0.6", "@types/turndown": "^5.0.6",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"concurrently": "^9.1.2", "concurrently": "^9.2.1",
"nx": "22.5.0", "nx": "22.5.2",
"tsx": "^4.19.3" "tsx": "^4.21.0"
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [
@@ -96,7 +96,8 @@
"packageManager": "pnpm@10.4.0", "packageManager": "pnpm@10.4.0",
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch" "react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch",
"@tiptap/core": "patches/@tiptap__core.patch"
}, },
"overrides": { "overrides": {
"jsdom": "25.0.1", "jsdom": "25.0.1",
@@ -112,6 +113,7 @@
"tmp": "0.2.5", "tmp": "0.2.5",
"lodash-es": "4.17.23", "lodash-es": "4.17.23",
"markdown-it": "14.1.1", "markdown-it": "14.1.1",
"ajv": "8.18.0",
"@tiptap/core": "3.17.1", "@tiptap/core": "3.17.1",
"@tiptap/pm": "3.17.1", "@tiptap/pm": "3.17.1",
"@tiptap/starter-kit": "3.17.1", "@tiptap/starter-kit": "3.17.1",
+1
View File
@@ -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";
+16 -3
View File
@@ -1,8 +1,21 @@
export type CalloutType = "default" | "info" | "success" | "warning" | "danger"; export type CalloutType =
const validCalloutTypes = ["default", "info", "success", "warning", "danger"]; | 'default'
| 'info'
| 'note'
| 'success'
| 'warning'
| 'danger';
const validCalloutTypes = [
'default',
'info',
'note',
'success',
'warning',
'danger',
];
export function getValidCalloutType(value: string): string { export function getValidCalloutType(value: string): string {
if (value) { if (value) {
return validCalloutTypes.includes(value) ? value : "info"; return validCalloutTypes.includes(value) ? value : 'info';
} }
} }
@@ -0,0 +1,127 @@
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
import { TextSelection } from "@tiptap/pm/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 true;
}
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,237 @@
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
import { Fragment, Node as PMNode } from "@tiptap/pm/model";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
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) =>
({ tr, state, dispatch }) => {
const layout = attributes?.layout || "two_equal";
const count = columnCountFromLayout(layout);
const columnType = state.schema.nodes.column;
const paraType = state.schema.nodes.paragraph;
const children = Array.from({ length: count }, () =>
columnType.create(null, paraType.create()),
);
const columnsNode = this.type.create(
attributes,
Fragment.from(children),
);
const stepsBefore = tr.steps.length;
tr.replaceSelectionWith(columnsNode);
if (tr.steps.length > stepsBefore) {
const stepMap = tr.steps[tr.steps.length - 1].getMap();
let insertStart = 0;
stepMap.forEach((_from, _to, newFrom) => {
insertStart = newFrom;
});
tr.setSelection(
TextSelection.near(tr.doc.resolve(insertStart + 1), 1),
);
}
if (dispatch) dispatch(tr);
return true;
},
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++) {
const col = columnsNode.child(j);
const nonEmpty: PMNode[] = [];
col.content.forEach((child) => {
if (
child.type.name !== "paragraph" ||
child.content.size > 0
) {
nonEmpty.push(child);
}
});
if (nonEmpty.length > 0) {
mergedContent = mergedContent.append(
Fragment.from(nonEmpty),
);
}
}
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);
tr.setSelection(
TextSelection.near(tr.doc.resolve(parentPos + 1), 1),
);
return true;
},
setColumnsLayout:
(layout) =>
({ commands }) =>
commands.updateAttributes("columns", { layout }),
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("columnsFocus"),
props: {
decorations: (state) => {
const parent = findParentNode(
(node) => node.type.name === "columns",
)(state.selection);
if (!parent) return DecorationSet.empty;
return DecorationSet.create(state.doc, [
Decoration.node(
parent.pos,
parent.pos + parent.node.nodeSize,
{ class: "has-focus" },
),
]);
},
},
}),
];
},
});
@@ -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";
+215 -8
View File
@@ -1,15 +1,35 @@
import { Node, mergeAttributes } from "@tiptap/core"; import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
import type { ResizableNodeViewDirection } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
export type DrawioResizeOptions = {
enabled: boolean;
directions?: ResizableNodeViewDirection[];
minWidth?: number;
minHeight?: number;
alwaysPreserveAspectRatio?: boolean;
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
className?: {
container?: string;
wrapper?: string;
handle?: string;
resizing?: string;
};
};
export interface DrawioOptions { export interface DrawioOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
view: any; view: any;
resize: DrawioResizeOptions | false;
} }
export interface DrawioAttributes { export interface DrawioAttributes {
src?: string; src?: string;
title?: string; title?: string;
size?: number; size?: number;
width?: string; width?: number | string;
height?: number;
aspectRatio?: number;
align?: string; align?: string;
attachmentId?: string; attachmentId?: string;
} }
@@ -18,6 +38,8 @@ declare module "@tiptap/core" {
interface Commands<ReturnType> { interface Commands<ReturnType> {
drawio: { drawio: {
setDrawio: (attributes?: DrawioAttributes) => ReturnType; setDrawio: (attributes?: DrawioAttributes) => ReturnType;
setDrawioAlign: (align: "left" | "center" | "right") => ReturnType;
setDrawioSize: (width: number, height: number) => ReturnType;
}; };
} }
} }
@@ -35,6 +57,7 @@ export const Drawio = Node.create<DrawioOptions>({
return { return {
HTMLAttributes: {}, HTMLAttributes: {},
view: null, view: null,
resize: false,
}; };
}, },
@@ -55,12 +78,30 @@ export const Drawio = Node.create<DrawioOptions>({
}), }),
}, },
width: { width: {
default: "100%", default: null,
parseHTML: (element) => element.getAttribute("data-width"), parseHTML: (element) => {
const raw = element.getAttribute("data-width");
if (!raw) return null;
if (raw.endsWith("%")) return raw;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: DrawioAttributes) => ({ renderHTML: (attributes: DrawioAttributes) => ({
"data-width": attributes.width, "data-width": attributes.width,
}), }),
}, },
height: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("data-height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: DrawioAttributes) => ({
"data-height": attributes.height,
}),
},
size: { size: {
default: null, default: null,
parseHTML: (element) => element.getAttribute("data-size"), parseHTML: (element) => element.getAttribute("data-size"),
@@ -68,6 +109,13 @@ export const Drawio = Node.create<DrawioOptions>({
"data-size": attributes.size, "data-size": attributes.size,
}), }),
}, },
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: DrawioAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
align: { align: {
default: "center", default: "center",
parseHTML: (element) => element.getAttribute("data-align"), parseHTML: (element) => element.getAttribute("data-align"),
@@ -99,7 +147,7 @@ export const Drawio = Node.create<DrawioOptions>({
mergeAttributes( mergeAttributes(
{ "data-type": this.name }, { "data-type": this.name },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes HTMLAttributes,
), ),
[ [
"img", "img",
@@ -122,13 +170,172 @@ export const Drawio = Node.create<DrawioOptions>({
attrs: attrs, attrs: attrs,
}); });
}, },
setDrawioAlign:
(align) =>
({ commands }) =>
commands.updateAttributes("drawio", { align }),
setDrawioSize:
(width, height) =>
({ commands }) =>
commands.updateAttributes("drawio", { width, height }),
}; };
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) const resize = this.options.resize;
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); if (!resize || !resize.enabled) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
const {
directions,
minWidth,
minHeight,
alwaysPreserveAspectRatio,
createCustomHandle,
className,
} = resize;
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
const el = document.createElement("img");
el.src = node.attrs.src;
el.alt = node.attrs.title || "";
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
let currentNode = node;
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (w, h) => {
el.style.width = `${w}px`;
el.style.height = `${h}px`;
},
onCommit: () => {
const pos = getPos();
if (pos === undefined) return;
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width: Math.round(el.offsetWidth),
height: Math.round(el.offsetHeight),
})
.run();
},
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
if (updatedNode.type !== currentNode.type) {
return false;
}
if (updatedNode.attrs.src !== currentNode.attrs.src) {
el.src = updatedNode.attrs.src || "";
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
el.style.width = `${w}px`;
}
if (h != null) {
el.style.height = `${h}px`;
}
const align = updatedNode.attrs.align || "center";
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
currentNode = updatedNode;
return true;
},
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
createCustomHandle,
className,
},
});
const dom = nodeView.dom as HTMLElement;
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Hide until image loads
dom.style.visibility = "hidden";
dom.style.pointerEvents = "none";
el.onload = () => {
dom.style.visibility = "";
dom.style.pointerEvents = "";
};
return nodeView;
};
}, },
}); });
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
+216 -8
View File
@@ -1,15 +1,35 @@
import { Node, mergeAttributes } from "@tiptap/core"; import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
import type { ResizableNodeViewDirection } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
export type ExcalidrawResizeOptions = {
enabled: boolean;
directions?: ResizableNodeViewDirection[];
minWidth?: number;
minHeight?: number;
alwaysPreserveAspectRatio?: boolean;
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
className?: {
container?: string;
wrapper?: string;
handle?: string;
resizing?: string;
};
};
export interface ExcalidrawOptions { export interface ExcalidrawOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
view: any; view: any;
resize: ExcalidrawResizeOptions | false;
} }
export interface ExcalidrawAttributes { export interface ExcalidrawAttributes {
src?: string; src?: string;
title?: string; title?: string;
size?: number; size?: number;
width?: string; width?: number | string;
height?: number;
aspectRatio?: number;
align?: string; align?: string;
attachmentId?: string; attachmentId?: string;
} }
@@ -18,6 +38,8 @@ declare module "@tiptap/core" {
interface Commands<ReturnType> { interface Commands<ReturnType> {
excalidraw: { excalidraw: {
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType; setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
setExcalidrawAlign: (align: "left" | "center" | "right") => ReturnType;
setExcalidrawSize: (width: number, height: number) => ReturnType;
}; };
} }
} }
@@ -35,8 +57,10 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
return { return {
HTMLAttributes: {}, HTMLAttributes: {},
view: null, view: null,
resize: false,
}; };
}, },
addAttributes() { addAttributes() {
return { return {
src: { src: {
@@ -54,12 +78,30 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
}), }),
}, },
width: { width: {
default: "100%", default: null,
parseHTML: (element) => element.getAttribute("data-width"), parseHTML: (element) => {
const raw = element.getAttribute("data-width");
if (!raw) return null;
if (raw.endsWith("%")) return raw;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ExcalidrawAttributes) => ({ renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-width": attributes.width, "data-width": attributes.width,
}), }),
}, },
height: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("data-height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-height": attributes.height,
}),
},
size: { size: {
default: null, default: null,
parseHTML: (element) => element.getAttribute("data-size"), parseHTML: (element) => element.getAttribute("data-size"),
@@ -67,6 +109,13 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
"data-size": attributes.size, "data-size": attributes.size,
}), }),
}, },
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
align: { align: {
default: "center", default: "center",
parseHTML: (element) => element.getAttribute("data-align"), parseHTML: (element) => element.getAttribute("data-align"),
@@ -98,7 +147,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
mergeAttributes( mergeAttributes(
{ "data-type": this.name }, { "data-type": this.name },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes HTMLAttributes,
), ),
[ [
"img", "img",
@@ -121,13 +170,172 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
attrs: attrs, attrs: attrs,
}); });
}, },
setExcalidrawAlign:
(align) =>
({ commands }) =>
commands.updateAttributes("excalidraw", { align }),
setExcalidrawSize:
(width, height) =>
({ commands }) =>
commands.updateAttributes("excalidraw", { width, height }),
}; };
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) const resize = this.options.resize;
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); if (!resize || !resize.enabled) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
const {
directions,
minWidth,
minHeight,
alwaysPreserveAspectRatio,
createCustomHandle,
className,
} = resize;
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
const el = document.createElement("img");
el.src = node.attrs.src;
el.alt = node.attrs.title || "";
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
let currentNode = node;
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (w, h) => {
el.style.width = `${w}px`;
el.style.height = `${h}px`;
},
onCommit: () => {
const pos = getPos();
if (pos === undefined) return;
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width: Math.round(el.offsetWidth),
height: Math.round(el.offsetHeight),
})
.run();
},
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
if (updatedNode.type !== currentNode.type) {
return false;
}
if (updatedNode.attrs.src !== currentNode.attrs.src) {
el.src = updatedNode.attrs.src || "";
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
el.style.width = `${w}px`;
}
if (h != null) {
el.style.height = `${h}px`;
}
const align = updatedNode.attrs.align || "center";
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
currentNode = updatedNode;
return true;
},
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
createCustomHandle,
className,
},
});
const dom = nodeView.dom as HTMLElement;
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Hide until image loads
dom.style.visibility = "hidden";
dom.style.pointerEvents = "none";
el.onload = () => {
dom.style.visibility = "";
dom.style.pointerEvents = "";
};
return nodeView;
};
}, },
}); });
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
@@ -2,8 +2,8 @@ import TiptapHeading, {
HeadingOptions as TiptapHeadingOptions, HeadingOptions as TiptapHeadingOptions,
} from "@tiptap/extension-heading"; } from "@tiptap/extension-heading";
import { mergeAttributes } from "@tiptap/react"; import { mergeAttributes } from "@tiptap/react";
import { Decoration, DecorationSet } from "prosemirror-view"; import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { Plugin } from "prosemirror-state"; import { Plugin } from "@tiptap/pm/state";
import { copyToClipboard } from "../utils"; import { copyToClipboard } from "../utils";
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`; const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
+230 -11
View File
@@ -1,18 +1,41 @@
import Image from "@tiptap/extension-image"; import Image from "@tiptap/extension-image";
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image"; import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { mergeAttributes, Range } from "@tiptap/core"; import {
mergeAttributes,
Range,
ResizableNodeView,
} from "@tiptap/core";
import type { ResizableNodeViewDirection } from "@tiptap/core";
export type ImageResizeOptions = {
enabled: boolean;
directions?: ResizableNodeViewDirection[];
minWidth?: number;
minHeight?: number;
alwaysPreserveAspectRatio?: boolean;
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
className?: {
container?: string;
wrapper?: string;
handle?: string;
resizing?: string;
};
};
export interface ImageOptions extends DefaultImageOptions { export interface ImageOptions extends DefaultImageOptions {
view: any; view: any;
resize: ImageResizeOptions | false;
} }
export interface ImageAttributes { export interface ImageAttributes {
src?: string; src?: string;
alt?: string; alt?: string;
align?: string; align?: string;
attachmentId?: string; attachmentId?: string;
size?: number; size?: number;
width?: number; width?: number | string;
height?: number;
aspectRatio?: number; aspectRatio?: number;
placeholder?: { placeholder?: {
id: string; id: string;
@@ -25,10 +48,11 @@ declare module "@tiptap/core" {
imageBlock: { imageBlock: {
setImage: (attributes: ImageAttributes) => ReturnType; setImage: (attributes: ImageAttributes) => ReturnType;
setImageAt: ( setImageAt: (
attributes: ImageAttributes & { pos: number | Range } attributes: ImageAttributes & { pos: number | Range },
) => ReturnType; ) => ReturnType;
setImageAlign: (align: "left" | "center" | "right") => ReturnType; setImageAlign: (align: "left" | "center" | "right") => ReturnType;
setImageWidth: (width: number) => ReturnType; setImageWidth: (width: number) => ReturnType;
setImageSize: (width: number, height: number) => ReturnType;
}; };
} }
} }
@@ -46,6 +70,7 @@ export const TiptapImage = Image.extend<ImageOptions>({
return { return {
...this.parent?.(), ...this.parent?.(),
view: null, view: null,
resize: false,
}; };
}, },
@@ -59,12 +84,30 @@ export const TiptapImage = Image.extend<ImageOptions>({
}), }),
}, },
width: { width: {
default: "100%", default: null,
parseHTML: (element) => element.getAttribute("width"), parseHTML: (element) => {
const raw = element.getAttribute("width");
if (!raw) return null;
if (raw.endsWith("%")) return raw;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ImageAttributes) => ({ renderHTML: (attributes: ImageAttributes) => ({
width: attributes.width, width: attributes.width,
}), }),
}, },
height: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ImageAttributes) => ({
height: attributes.height,
}),
},
align: { align: {
default: "center", default: "center",
parseHTML: (element) => element.getAttribute("data-align"), parseHTML: (element) => element.getAttribute("data-align"),
@@ -142,16 +185,192 @@ export const TiptapImage = Image.extend<ImageOptions>({
setImageWidth: setImageWidth:
(width) => (width) =>
({ commands }) => ({ commands }) =>
commands.updateAttributes("image", { commands.updateAttributes("image", { width }),
width: `${Math.max(0, Math.min(100, width))}%`,
}), setImageSize:
(width, height) =>
({ commands }) =>
commands.updateAttributes("image", { width, height }),
}; };
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) const resize = this.options.resize;
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); if (!resize || !resize.enabled) {
// Fallback to React node view (existing behavior)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
const {
directions,
minWidth,
minHeight,
alwaysPreserveAspectRatio,
createCustomHandle,
className,
} = resize;
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
// If no src yet (placeholder/uploading), use React view for loading UI
if (!HTMLAttributes.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
// When the node gets a src, return false from update to force rebuild
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
// Has src — use ResizableNodeView
const el = document.createElement("img");
Object.entries(HTMLAttributes).forEach(([key, value]) => {
if (value != null) {
switch (key) {
case "width":
case "height":
break;
default:
el.setAttribute(key, String(value));
break;
}
}
});
el.src = HTMLAttributes.src;
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
let currentNode = node;
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (w, h) => {
el.style.width = `${w}px`;
el.style.height = `${h}px`;
},
onCommit: () => {
const pos = getPos();
if (pos === undefined) return;
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width: Math.round(el.offsetWidth),
height: Math.round(el.offsetHeight),
})
.run();
},
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
if (updatedNode.type !== currentNode.type) {
return false;
}
if (updatedNode.attrs.src !== currentNode.attrs.src) {
el.src = updatedNode.attrs.src || "";
}
if (updatedNode.attrs.alt !== currentNode.attrs.alt) {
el.alt = updatedNode.attrs.alt || "";
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
el.style.width = `${w}px`;
}
if (h != null) {
el.style.height = `${h}px`;
}
// Update alignment on container
const align = updatedNode.attrs.align || "center";
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
currentNode = updatedNode;
return true;
},
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
createCustomHandle,
className,
},
});
const dom = nodeView.dom as HTMLElement;
// Apply initial alignment
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
// Defer conversion until we can measure the container
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Hide until image loads (official TipTap pattern)
dom.style.visibility = "hidden";
dom.style.pointerEvents = "none";
el.onload = () => {
dom.style.visibility = "";
dom.style.pointerEvents = "";
};
return nodeView;
};
}, },
}); });
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
+201 -7
View File
@@ -1,16 +1,35 @@
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { Range, Node } from "@tiptap/core"; import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
import type { ResizableNodeViewDirection } from "@tiptap/core";
export type VideoResizeOptions = {
enabled: boolean;
directions?: ResizableNodeViewDirection[];
minWidth?: number;
minHeight?: number;
alwaysPreserveAspectRatio?: boolean;
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
className?: {
container?: string;
wrapper?: string;
handle?: string;
resizing?: string;
};
};
export interface VideoOptions { export interface VideoOptions {
view: any; view: any;
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
resize: VideoResizeOptions | false;
} }
export interface VideoAttributes { export interface VideoAttributes {
src?: string; src?: string;
align?: string; align?: string;
attachmentId?: string; attachmentId?: string;
size?: number; size?: number;
width?: number; width?: number | string;
height?: number;
aspectRatio?: number; aspectRatio?: number;
placeholder?: { placeholder?: {
id: string; id: string;
@@ -27,6 +46,7 @@ declare module "@tiptap/core" {
) => ReturnType; ) => ReturnType;
setVideoAlign: (align: "left" | "center" | "right") => ReturnType; setVideoAlign: (align: "left" | "center" | "right") => ReturnType;
setVideoWidth: (width: number) => ReturnType; setVideoWidth: (width: number) => ReturnType;
setVideoSize: (width: number, height: number) => ReturnType;
}; };
} }
} }
@@ -44,6 +64,7 @@ export const TiptapVideo = Node.create<VideoOptions>({
return { return {
view: null, view: null,
HTMLAttributes: {}, HTMLAttributes: {},
resize: false,
}; };
}, },
@@ -64,12 +85,30 @@ export const TiptapVideo = Node.create<VideoOptions>({
}), }),
}, },
width: { width: {
default: "100%", default: null,
parseHTML: (element) => element.getAttribute("width"), parseHTML: (element) => {
const raw = element.getAttribute("width");
if (!raw) return null;
if (raw.endsWith("%")) return raw;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: VideoAttributes) => ({ renderHTML: (attributes: VideoAttributes) => ({
width: attributes.width, width: attributes.width,
}), }),
}, },
height: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: VideoAttributes) => ({
height: attributes.height,
}),
},
size: { size: {
default: null, default: null,
parseHTML: (element) => element.getAttribute("data-size"), parseHTML: (element) => element.getAttribute("data-size"),
@@ -136,13 +175,168 @@ export const TiptapVideo = Node.create<VideoOptions>({
commands.updateAttributes("video", { commands.updateAttributes("video", {
width: `${Math.max(0, Math.min(100, width))}%`, width: `${Math.max(0, Math.min(100, width))}%`,
}), }),
setVideoSize:
(width, height) =>
({ commands }) =>
commands.updateAttributes("video", { width, height }),
}; };
}, },
addNodeView() { addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) const resize = this.options.resize;
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view); if (!resize || !resize.enabled) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
const {
directions,
minWidth,
minHeight,
alwaysPreserveAspectRatio,
createCustomHandle,
className,
} = resize;
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
const el = document.createElement("video");
el.src = node.attrs.src;
el.controls = true;
el.preload = "metadata";
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
let currentNode = node;
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (w, h) => {
el.style.width = `${w}px`;
el.style.height = `${h}px`;
},
onCommit: () => {
const pos = getPos();
if (pos === undefined) return;
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width: Math.round(el.offsetWidth),
height: Math.round(el.offsetHeight),
})
.run();
},
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
if (updatedNode.type !== currentNode.type) {
return false;
}
if (updatedNode.attrs.src !== currentNode.attrs.src) {
el.src = updatedNode.attrs.src || "";
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
el.style.width = `${w}px`;
}
if (h != null) {
el.style.height = `${h}px`;
}
const align = updatedNode.attrs.align || "center";
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
currentNode = updatedNode;
return true;
},
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
createCustomHandle,
className,
},
});
const dom = nodeView.dom as HTMLElement;
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Hide until video metadata loads
dom.style.visibility = "hidden";
dom.style.pointerEvents = "none";
el.onloadedmetadata = () => {
dom.style.visibility = "";
dom.style.pointerEvents = "";
};
return nodeView;
};
}, },
}); });
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
+105
View File
@@ -0,0 +1,105 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 01d6999642c5ae990083798a1bf0ef87068e4192..891b13c6901f28a6ab413c6dbae0ea726a76a196 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -5463,7 +5463,10 @@ var ResizableNodeView = class {
this.container.classList.remove(this.classNames.resizing);
}
document.removeEventListener("mousemove", this.handleMouseMove);
+ document.removeEventListener("touchmove", this.handleTouchMove);
document.removeEventListener("mouseup", this.handleMouseUp);
+ document.removeEventListener("touchend", this.handleMouseUp);
+ window.removeEventListener("blur", this.handleMouseUp);
document.removeEventListener("keydown", this.handleKeyDown);
document.removeEventListener("keyup", this.handleKeyUp);
};
@@ -5593,7 +5596,10 @@ var ResizableNodeView = class {
this.container.classList.remove(this.classNames.resizing);
}
document.removeEventListener("mousemove", this.handleMouseMove);
+ document.removeEventListener("touchmove", this.handleTouchMove);
document.removeEventListener("mouseup", this.handleMouseUp);
+ document.removeEventListener("touchend", this.handleMouseUp);
+ window.removeEventListener("blur", this.handleMouseUp);
document.removeEventListener("keydown", this.handleKeyDown);
document.removeEventListener("keyup", this.handleKeyUp);
this.isResizing = false;
@@ -5796,6 +5802,8 @@ var ResizableNodeView = class {
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("touchmove", this.handleTouchMove);
document.addEventListener("mouseup", this.handleMouseUp);
+ document.addEventListener("touchend", this.handleMouseUp);
+ window.addEventListener("blur", this.handleMouseUp);
document.addEventListener("keydown", this.handleKeyDown);
document.addEventListener("keyup", this.handleKeyUp);
}
diff --git a/dist/index.js b/dist/index.js
index 6f357a03b038abeb5ed86967b7fc7c3e5eb1d2d6..2d2742532860821984e1ba82625821504538ebbe 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -5330,7 +5330,10 @@ var ResizableNodeView = class {
this.container.classList.remove(this.classNames.resizing);
}
document.removeEventListener("mousemove", this.handleMouseMove);
+ document.removeEventListener("touchmove", this.handleTouchMove);
document.removeEventListener("mouseup", this.handleMouseUp);
+ document.removeEventListener("touchend", this.handleMouseUp);
+ window.removeEventListener("blur", this.handleMouseUp);
document.removeEventListener("keydown", this.handleKeyDown);
document.removeEventListener("keyup", this.handleKeyUp);
};
@@ -5460,7 +5463,10 @@ var ResizableNodeView = class {
this.container.classList.remove(this.classNames.resizing);
}
document.removeEventListener("mousemove", this.handleMouseMove);
+ document.removeEventListener("touchmove", this.handleTouchMove);
document.removeEventListener("mouseup", this.handleMouseUp);
+ document.removeEventListener("touchend", this.handleMouseUp);
+ window.removeEventListener("blur", this.handleMouseUp);
document.removeEventListener("keydown", this.handleKeyDown);
document.removeEventListener("keyup", this.handleKeyUp);
this.isResizing = false;
@@ -5663,6 +5669,8 @@ var ResizableNodeView = class {
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("touchmove", this.handleTouchMove);
document.addEventListener("mouseup", this.handleMouseUp);
+ document.addEventListener("touchend", this.handleMouseUp);
+ window.addEventListener("blur", this.handleMouseUp);
document.addEventListener("keydown", this.handleKeyDown);
document.addEventListener("keyup", this.handleKeyUp);
}
diff --git a/src/lib/ResizableNodeView.ts b/src/lib/ResizableNodeView.ts
index f13e210b0aa46aefe7c31105deee3d2aa8a26cd5..9bac138dbf17c6ae6c3c129cbedb3a81bd39b60c 100644
--- a/src/lib/ResizableNodeView.ts
+++ b/src/lib/ResizableNodeView.ts
@@ -523,7 +523,10 @@ export class ResizableNodeView {
}
document.removeEventListener('mousemove', this.handleMouseMove)
+ document.removeEventListener('touchmove', this.handleTouchMove)
document.removeEventListener('mouseup', this.handleMouseUp)
+ document.removeEventListener('touchend', this.handleMouseUp)
+ window.removeEventListener('blur', this.handleMouseUp)
document.removeEventListener('keydown', this.handleKeyDown)
document.removeEventListener('keyup', this.handleKeyUp)
this.isResizing = false
@@ -774,6 +777,8 @@ export class ResizableNodeView {
document.addEventListener('mousemove', this.handleMouseMove)
document.addEventListener('touchmove', this.handleTouchMove)
document.addEventListener('mouseup', this.handleMouseUp)
+ document.addEventListener('touchend', this.handleMouseUp)
+ window.addEventListener('blur', this.handleMouseUp)
document.addEventListener('keydown', this.handleKeyDown)
document.addEventListener('keyup', this.handleKeyUp)
}
@@ -859,7 +864,10 @@ export class ResizableNodeView {
// Clean up document-level listeners
document.removeEventListener('mousemove', this.handleMouseMove)
+ document.removeEventListener('touchmove', this.handleTouchMove)
document.removeEventListener('mouseup', this.handleMouseUp)
+ document.removeEventListener('touchend', this.handleMouseUp)
+ window.removeEventListener('blur', this.handleMouseUp)
document.removeEventListener('keydown', this.handleKeyDown)
document.removeEventListener('keyup', this.handleKeyUp)
}
+1538 -1567
View File
File diff suppressed because it is too large Load Diff