Merge branch 'main' into feat/bases

This commit is contained in:
Philipinho
2026-06-20 23:17:57 +01:00
15 changed files with 49 additions and 20 deletions
@@ -23,7 +23,7 @@ import {
} from "@/features/comment/atoms/comment-atom"; } from "@/features/comment/atoms/comment-atom";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { v7 as uuid7 } from "uuid"; import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms"; import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
@@ -226,7 +226,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
aria-label={t(item.name)} aria-label={t(item.name)}
className={clsx({ [classes.active]: item.isActive() })} className={clsx({ [classes.active]: item.isActive() })}
style={{ border: "none" }} style={{ border: "none" }}
onClick={item.command} onClick={() => isEditorReady(props.editor) && item.command()}
> >
<item.icon style={{ width: rem(16) }} stroke={2} /> <item.icon style={{ width: rem(16) }} stroke={2} />
</ActionIcon> </ActionIcon>
@@ -256,7 +256,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
radius="6px" radius="6px"
aria-label={t(commentItem.name)} aria-label={t(commentItem.name)}
style={{ border: "none" }} style={{ border: "none" }}
onClick={commentItem.command} onClick={() => isEditorReady(props.editor) && commentItem.command()}
> >
<IconMessage size={16} stroke={2} /> <IconMessage size={16} stroke={2} />
</ActionIcon> </ActionIcon>
@@ -13,6 +13,7 @@ import {
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isEditorReady } from "@docmost/editor-ext";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./bubble-menu.module.css"; import classes from "./bubble-menu.module.css";
@@ -253,6 +254,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<SimpleGrid cols={5} spacing="xs"> <SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => { {TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => { const applyTextColor = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") { if (name === "Default") {
editor.commands.unsetColor(); editor.commands.unsetColor();
} else { } else {
@@ -316,6 +318,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
<SimpleGrid cols={5} spacing="xs"> <SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => { {HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => { const applyHighlight = () => {
if (!isEditorReady(editor)) return;
if (name === "Default") { if (name === "Default") {
editor.commands.unsetHighlight(); editor.commands.unsetHighlight();
} else { } else {
@@ -386,8 +389,10 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
data-color-grid="remove" data-color-grid="remove"
className={classes.removeColor} className={classes.removeColor}
onClick={() => { onClick={() => {
editor.commands.unsetColor(); if (isEditorReady(editor)) {
editor.commands.unsetHighlight(); editor.commands.unsetColor();
editor.commands.unsetHighlight();
}
setIsOpen(false); setIsOpen(false);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -19,6 +19,7 @@ import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isEditorReady } from "@docmost/editor-ext";
import classes from "./bubble-menu.module.css"; import classes from "./bubble-menu.module.css";
interface NodeSelectorProps { interface NodeSelectorProps {
@@ -193,7 +194,7 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
justify="left" justify="left"
fullWidth fullWidth
onClick={() => { onClick={() => {
item.command(); if (isEditorReady(editor)) item.command();
setIsOpen(false); setIsOpen(false);
}} }}
style={{ border: "none" }} style={{ border: "none" }}
@@ -11,6 +11,7 @@ import { Menu, Button, Tooltip, rem } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isEditorReady } from "@docmost/editor-ext";
interface TextAlignmentProps { interface TextAlignmentProps {
editor: Editor | null; editor: Editor | null;
@@ -117,7 +118,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
activeItem.name === item.name ? <IconCheck size={16} /> : null activeItem.name === item.name ? <IconCheck size={16} /> : null
} }
onClick={() => { onClick={() => {
item.command(); if (isEditorReady(editor)) item.command();
setIsOpen(false); setIsOpen(false);
}} }}
> >
@@ -183,6 +183,7 @@ async function reuploadPastedAttachments(
); );
if (reuploadResults.size === 0) return; if (reuploadResults.size === 0) return;
if (editor.isDestroyed) return;
editor.chain().command(({ tr }) => { editor.chain().command(({ tr }) => {
const sorted = [...nodesToReupload].sort((a, b) => b.pos - a.pos); const sorted = [...nodesToReupload].sort((a, b) => b.pos - a.pos);
@@ -25,11 +25,11 @@ export const BlockTypeGroup: FC<Props> = ({ editor }) => {
const state = useEditorState({ const state = useEditorState({
editor, editor,
selector: (ctx) => ({ selector: (ctx) => ({
isHeading1: ctx.editor.isActive("heading", { level: 1 }), isHeading1: !!ctx.editor?.isActive("heading", { level: 1 }),
isHeading2: ctx.editor.isActive("heading", { level: 2 }), isHeading2: !!ctx.editor?.isActive("heading", { level: 2 }),
isHeading3: ctx.editor.isActive("heading", { level: 3 }), isHeading3: !!ctx.editor?.isActive("heading", { level: 3 }),
isBlockquote: ctx.editor.isActive("blockquote"), isBlockquote: !!ctx.editor?.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"), isCodeBlock: !!ctx.editor?.isActive("codeBlock"),
}), }),
}); });
@@ -21,7 +21,7 @@ export interface ToolbarState {
// static editor (mainExtensions only, undoRedo disabled), neither is loaded // static editor (mainExtensions only, undoRedo disabled), neither is loaded
// and editor.can().undo/redo is undefined. // and editor.can().undo/redo is undefined.
function safeCan(editor: Editor, command: "undo" | "redo"): boolean { function safeCan(editor: Editor, command: "undo" | "redo"): boolean {
const can = editor.can() as Record<string, unknown>; const can = editor?.can() as Record<string, unknown>;
const fn = can[command]; const fn = can[command];
return typeof fn === "function" ? (fn as () => boolean)() : false; return typeof fn === "function" ? (fn as () => boolean)() : false;
} }
@@ -30,7 +30,7 @@ export function useToolbarState(editor: Editor | null): ToolbarState | null {
return useEditorState({ return useEditorState({
editor, editor,
selector: (ctx) => { selector: (ctx) => {
if (!ctx.editor) return null; if (!ctx.editor || ctx.editor.isDestroyed) return null;
return { return {
isBold: ctx.editor.isActive("bold"), isBold: ctx.editor.isActive("bold"),
isItalic: ctx.editor.isActive("italic"), isItalic: ctx.editor.isActive("italic"),
@@ -25,7 +25,7 @@ const recalculateLinks = (nodePos: NodePos[]) => {
(acc, item) => { (acc, item) => {
const label = item.node.textContent; const label = item.node.textContent;
const level = Number(item.node.attrs.level); const level = Number(item.node.attrs.level);
if (label.length && level <= 4) { if (label.length && level <= 6) {
acc.push({ acc.push({
label, label,
level, level,
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { useTableHandleDrag } from "./hooks/use-table-handle-drag"; import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle"; import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
import { ColumnHandleMenu } from "./menus/column-handle-menu"; import { ColumnHandleMenu } from "./menus/column-handle-menu";
import { isEditorReady } from "@docmost/editor-ext";
import classes from "./handle.module.css"; import classes from "./handle.module.css";
interface ColumnHandleProps { interface ColumnHandleProps {
@@ -35,7 +36,9 @@ export const ColumnHandle = React.memo(function ColumnHandle({
// an external drop reflows the doc before the plugin re-emits // an external drop reflows the doc before the plugin re-emits
// hoveringCell), it can resolve to a Text node, on which `.closest` is // hoveringCell), it can resolve to a Text node, on which `.closest` is
// undefined. Filter to HTMLElement so downstream consumers stay safe. // undefined. Filter to HTMLElement so downstream consumers stay safe.
const lookupDom = editor.view.nodeDOM(anchorPos); const lookupDom = isEditorReady(editor)
? editor.view.nodeDOM(anchorPos)
: null;
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null; const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom); const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom); const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
@@ -101,14 +101,14 @@ export const CellChevronMenu = React.memo(function CellChevronMenu({
<Menu.Item <Menu.Item
leftSection={<IconBoxMargin size={16} />} leftSection={<IconBoxMargin size={16} />}
onClick={() => editor.chain().focus().mergeCells().run()} onClick={() => editor.chain().focus().mergeCells().run()}
disabled={!editor.can().mergeCells()} disabled={!editor?.can().mergeCells()}
> >
{t("Merge cells")} {t("Merge cells")}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
leftSection={<IconSquareToggle size={16} />} leftSection={<IconSquareToggle size={16} />}
onClick={() => editor.chain().focus().splitCell().run()} onClick={() => editor.chain().focus().splitCell().run()}
disabled={!editor.can().splitCell()} disabled={!editor?.can().splitCell()}
> >
{t("Split cell")} {t("Split cell")}
</Menu.Item> </Menu.Item>
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { useTableHandleDrag } from "./hooks/use-table-handle-drag"; import { useTableHandleDrag } from "./hooks/use-table-handle-drag";
import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle"; import { useColumnRowMenuLifecycle } from "./hooks/use-column-row-menu-lifecycle";
import { RowHandleMenu } from "./menus/row-handle-menu"; import { RowHandleMenu } from "./menus/row-handle-menu";
import { isEditorReady } from "@docmost/editor-ext";
import classes from "./handle.module.css"; import classes from "./handle.module.css";
interface RowHandleProps { interface RowHandleProps {
@@ -33,7 +34,9 @@ export const RowHandle = React.memo(function RowHandle({
// an external drop reflows the doc before the plugin re-emits // an external drop reflows the doc before the plugin re-emits
// hoveringCell), it can resolve to a Text node, on which `.closest` is // hoveringCell), it can resolve to a Text node, on which `.closest` is
// undefined. Filter to HTMLElement so downstream consumers stay safe. // undefined. Filter to HTMLElement so downstream consumers stay safe.
const lookupDom = editor.view.nodeDOM(anchorPos); const lookupDom = isEditorReady(editor)
? editor.view.nodeDOM(anchorPos)
: null;
const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null; const lookupCellDom = lookupDom instanceof HTMLElement ? lookupDom : null;
const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom); const [cellDom, setCellDom] = useState<HTMLElement | null>(lookupCellDom);
const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom); const lastCellDomRef = useRef<HTMLElement | null>(lookupCellDom);
@@ -105,6 +105,7 @@ function TransclusionReferenceBody({
sourcePageId, sourcePageId,
transclusionId, transclusionId,
}); });
if (editor.isDestroyed) return;
const pos = getPos(); const pos = getPos();
if (typeof pos !== "number") return; if (typeof pos !== "number") return;
const from = pos; const from = pos;
@@ -42,6 +42,10 @@ export const useEditorScroll = ({
return; return;
} }
if (editor.isDestroyed) {
resolve(false);
return;
}
const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`); const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`);
if (dom) { if (dom) {
dom.scrollIntoView({ behavior: 'smooth', block: 'start' }); dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -468,7 +468,9 @@ export default function PageEditor({
)} )}
</div> </div>
<div <div
onClick={() => editor.commands.focus("end")} onClick={() => {
if (editor && !editor.isDestroyed) editor.commands.focus("end");
}}
style={{ paddingBottom: "20vh" }} style={{ paddingBottom: "20vh" }}
></div> ></div>
</div> </div>
@@ -42,6 +42,14 @@ export function useHistoryRestore() {
const handleRestore = useCallback(() => { const handleRestore = useCallback(() => {
if (!activeHistoryData) return; if (!activeHistoryData) return;
if (
!mainEditor ||
mainEditor.isDestroyed ||
!mainEditorTitle ||
mainEditorTitle.isDestroyed
) {
return;
}
mainEditorTitle mainEditorTitle
.chain() .chain()