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
16 changed files with 1601 additions and 1464 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",
@@ -1,7 +1,7 @@
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,
@@ -1,7 +1,7 @@
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, useState } from "react"; import React, { useCallback, useRef, useState } from "react";
import { Node as PMNode } from "prosemirror-model"; import { DOMSerializer, Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -16,6 +16,8 @@ import {
IconLayoutSidebar, IconLayoutSidebar,
IconLayoutSidebarRight, IconLayoutSidebarRight,
IconLayoutAlignCenter, IconLayoutAlignCenter,
IconCopy,
IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { isTextSelected } from "@docmost/editor-ext"; import { isTextSelected } from "@docmost/editor-ext";
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext"; import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
@@ -54,8 +56,7 @@ const threeColumnPresets: LayoutPreset[] = [
label: "Left wide", label: "Left wide",
icon: IconLayoutSidebarRight, icon: IconLayoutSidebarRight,
}, },
{ layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar { layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar },
},
]; ];
function getPresetsForCount(count: number): LayoutPreset[] { function getPresetsForCount(count: number): LayoutPreset[] {
@@ -67,6 +68,8 @@ function getPresetsForCount(count: number): LayoutPreset[] {
export function ColumnsMenu({ editor }: EditorMenuProps) { export function ColumnsMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isCountOpen, setIsCountOpen] = useState(false); const [isCountOpen, setIsCountOpen] = useState(false);
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
const nodesWithMenus = [ const nodesWithMenus = [
"callout", "callout",
@@ -187,6 +190,65 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
[editor], [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 columnCount = editorState?.columnCount || 2;
const currentLayout = editorState?.layout || "two_equal"; const currentLayout = editorState?.layout || "two_equal";
const presets = getPresetsForCount(columnCount); const presets = getPresetsForCount(columnCount);
@@ -259,6 +321,38 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
</ActionIcon> </ActionIcon>
</Tooltip> </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> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
); );
@@ -1,7 +1,7 @@
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, useRef, useState } 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,
@@ -1,7 +1,7 @@
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 { lazy, Suspense, useCallback, useState } 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,
@@ -1,7 +1,7 @@
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, useRef } 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,
@@ -1,7 +1,7 @@
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 } from "react";
import { Node as PMNode } from "prosemirror-model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
ShouldShowProps, ShouldShowProps,
@@ -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";
@@ -113,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,
@@ -1,12 +1,17 @@
div[data-type="columns"] { div[data-type="columns"] {
display: flex; display: flex;
margin: 0.75rem 0; margin: 0.75rem 0;
padding: 0.5em; padding: 0.5em 0;
} }
div[data-type="columns"] > div[data-type="column"] { div[data-type="columns"] > div[data-type="column"] {
flex: 1; flex: 1;
min-width: 0; 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"] { div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
@@ -16,6 +21,9 @@ div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
} }
div[data-type="columns"]:hover 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"]
+ div[data-type="column"] { + div[data-type="column"] {
border-left: 1px solid border-left: 1px solid
@@ -66,7 +74,7 @@ div[data-type="columns"][data-layout="three_with_sidebars"]
} }
/* Stack columns vertically on small viewports */ /* Stack columns vertically on small viewports */
@media (max-width: 820px) { @media (max-width: 680px) {
div[data-type="columns"] { div[data-type="columns"] {
flex-direction: column; flex-direction: column;
} }
+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",
@@ -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);
+5 -4
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": [
@@ -113,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,5 +1,5 @@
import { Node, mergeAttributes, findParentNode } from "@tiptap/core"; import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
import { TextSelection } from "prosemirror-state"; import { TextSelection } from "@tiptap/pm/state";
export interface ColumnOptions { export interface ColumnOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
+44 -3
View File
@@ -1,6 +1,7 @@
import { Node, mergeAttributes, findParentNode } from "@tiptap/core"; import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
import { Fragment, Node as PMNode } from "prosemirror-model"; import { Fragment, Node as PMNode } from "@tiptap/pm/model";
import { TextSelection } from "prosemirror-state"; import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
export type ColumnsLayout = export type ColumnsLayout =
| "two_equal" | "two_equal"
@@ -173,7 +174,21 @@ export const Columns = Node.create<ColumnsOptions>({
} }
let mergedContent = columnsNode.child(count - 1).content; let mergedContent = columnsNode.child(count - 1).content;
for (let j = count; j < currentCount; j++) { for (let j = count; j < currentCount; j++) {
mergedContent = mergedContent.append(columnsNode.child(j).content); 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)); newChildren.push(columnType.create(null, mergedContent));
} }
@@ -184,6 +199,9 @@ export const Columns = Node.create<ColumnsOptions>({
Fragment.from(newChildren), Fragment.from(newChildren),
); );
tr.replaceWith(parentPos, parentPos + columnsNode.nodeSize, newNode); tr.replaceWith(parentPos, parentPos + columnsNode.nodeSize, newNode);
tr.setSelection(
TextSelection.near(tr.doc.resolve(parentPos + 1), 1),
);
return true; return true;
}, },
@@ -193,4 +211,27 @@ export const Columns = Node.create<ColumnsOptions>({
commands.updateAttributes("columns", { layout }), 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" },
),
]);
},
},
}),
];
},
}); });
@@ -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>`;
+1397 -1429
View File
File diff suppressed because it is too large Load Diff