feat: editor inline status node (#1973)

* inline status node

* fix alignment

* fix

* typed storage

* fix math block popup on select all
This commit is contained in:
Philip Okugbe
2026-02-27 01:34:03 +00:00
committed by GitHub
parent 59e945562d
commit ea44468fad
11 changed files with 365 additions and 7 deletions
@@ -56,8 +56,11 @@ export default function MathBlockView(props: NodeViewProps) {
}, [debouncedPreview]);
useEffect(() => {
setIsEditing(!!props.selected);
if (props.selected) setPreview(node.attrs.text);
const pos = getPos();
const { from, to } = editor.state.selection;
const nodeSelected = props.selected && from === pos && to === pos + node.nodeSize;
setIsEditing(nodeSelected);
if (nodeSelected) setPreview(node.attrs.text);
}, [props.selected]);
return (
@@ -46,8 +46,11 @@ export default function MathInlineView(props: NodeViewProps) {
}, [preview, isEditing]);
useEffect(() => {
setIsEditing(!!props.selected);
if (props.selected) setPreview(node.attrs.text);
const pos = getPos();
const { from, to } = editor.state.selection;
const nodeSelected = props.selected && from === pos && to === pos + node.nodeSize;
setIsEditing(nodeSelected);
if (nodeSelected) setPreview(node.attrs.text);
}, [props.selected]);
return (
@@ -22,6 +22,7 @@ import {
IconSitemap,
IconColumns3,
IconColumns2,
IconTag,
} from "@tabler/icons-react";
import {
CommandProps,
@@ -385,6 +386,20 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
{
title: "Status",
description: "Insert inline status badge.",
searchTerms: ["status", "badge", "label", "lozenge"],
icon: IconTag,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setStatus({ text: "", color: "gray" })
.run();
},
},
{
title: "Subpages (Child pages)",
description: "List all subpages of the current page",
@@ -0,0 +1,138 @@
import { useState, useRef, useEffect } from "react";
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Popover, TextInput, Group, Box } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { IconCheck } from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./status.module.css";
import type { StatusColor } from "@docmost/editor-ext";
const STATUS_COLORS: { name: StatusColor; bg: string }[] = [
{ name: "gray", bg: "var(--mantine-color-gray-4)" },
{ name: "blue", bg: "var(--mantine-color-blue-4)" },
{ name: "green", bg: "var(--mantine-color-green-4)" },
{ name: "yellow", bg: "var(--mantine-color-yellow-4)" },
{ name: "red", bg: "var(--mantine-color-red-4)" },
{ name: "purple", bg: "var(--mantine-color-violet-4)" },
];
const colorClassMap: Record<StatusColor, string> = {
gray: classes.colorGray,
blue: classes.colorBlue,
green: classes.colorGreen,
yellow: classes.colorYellow,
red: classes.colorRed,
purple: classes.colorPurple,
};
export default function StatusView(props: NodeViewProps) {
const { node, updateAttributes, deleteNode, editor } = props;
const { text, color } = node.attrs as {
text: string;
color: StatusColor;
};
const [opened, setOpened] = useState(false);
const [inputValue, setInputValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const storage = editor.storage?.status;
if (storage?.autoOpen) {
storage.autoOpen = false;
setOpened(true);
}
}, []);
useEffect(() => {
if (opened) {
setInputValue(text);
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [opened]);
const debouncedUpdateAttributes = useDebouncedCallback(
(val: string) => updateAttributes({ text: val }),
100,
);
const handleTextChange = (val: string) => {
setInputValue(val);
debouncedUpdateAttributes(val);
};
const handleColorChange = (newColor: StatusColor) => {
updateAttributes({ color: newColor });
};
const isEditable = editor.isEditable;
return (
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
<Popover
opened={opened}
onChange={(open) => {
if (!open && !text) {
deleteNode();
return;
}
setOpened(open);
}}
width={220}
position="bottom"
withArrow
shadow="md"
trapFocus
>
<Popover.Target>
<span
className={clsx(
"status-badge",
classes.status,
colorClassMap[color],
)}
onClick={() => isEditable && setOpened(true)}
role="button"
tabIndex={0}
>
{text || "SET STATUS"}
</span>
</Popover.Target>
<Popover.Dropdown>
<TextInput
ref={inputRef}
value={inputValue}
onChange={(e) =>
handleTextChange(e.currentTarget.value.toUpperCase())
}
onKeyDown={(e) => {
if (e.key === "Enter") {
setOpened(false);
}
}}
placeholder="Status text"
size="sm"
mb="xs"
/>
<Group gap={6} justify="center">
{STATUS_COLORS.map(({ name, bg }) => (
<Box
key={name}
className={clsx(
classes.swatch,
color === name && classes.swatchActive,
)}
style={{ backgroundColor: bg }}
onClick={() => handleColorChange(name)}
>
{color === name && <IconCheck size={14} />}
</Box>
))}
</Group>
</Popover.Dropdown>
</Popover>
</NodeViewWrapper>
);
}
@@ -0,0 +1,65 @@
.status {
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 3px;
padding: 1px 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
line-height: 1.6;
cursor: pointer;
white-space: nowrap;
vertical-align: middle;
user-select: none;
}
.colorGray {
background-color: light-dark(rgb(223 223 215), rgba(168, 162, 158, 0.4));
color: light-dark(#3d3d3d, var(--mantine-color-gray-3));
}
.colorBlue {
background-color: light-dark(rgb(191 227 253), rgba(37, 99, 235, 0.4));
color: light-dark(#1a4d99, var(--mantine-color-blue-3));
}
.colorGreen {
background-color: light-dark(rgb(187 240 173), rgba(0, 138, 0, 0.4));
color: light-dark(#135c13, var(--mantine-color-green-3));
}
.colorYellow {
background-color: light-dark(rgb(249 238 148), rgba(234, 179, 8, 0.4));
color: light-dark(#6b5300, var(--mantine-color-yellow-3));
}
.colorRed {
background-color: light-dark(rgb(255 200 195), rgba(224, 0, 0, 0.4));
color: light-dark(#a10000, var(--mantine-color-red-3));
}
.colorPurple {
background-color: light-dark(rgb(225 207 245), rgba(147, 51, 234, 0.4));
color: light-dark(#5b21a6, var(--mantine-color-violet-3));
}
.swatch {
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
border: 2px solid transparent;
display: flex;
align-items: center;
justify-content: center;
}
.swatch:hover {
opacity: 0.8;
}
.swatchActive {
border-color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4));
}
@@ -47,15 +47,13 @@ import {
SharedStorage,
Columns,
Column,
Status
} from "@docmost/editor-ext";
import {
randomElement,
userColors,
} from "@/features/editor/extensions/utils.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
import ImageView from "@/features/editor/components/image/image-view.tsx";
import {
createImageHandle,
imageResizeClasses,
@@ -64,7 +62,11 @@ import {
createResizeHandle,
buildResizeClasses,
} from "@/features/editor/components/common/node-resize-handles.ts";
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import StatusView from "@/features/editor/components/status/status-view.tsx";
import VideoView from "@/features/editor/components/video/video-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
@@ -309,6 +311,9 @@ export const mainExtensions = [
Subpages.configure({
view: SubpagesView,
}),
Status.configure({
view: StatusView,
}),
MarkdownClipboard.configure({
transformPastedText: true,
}),
@@ -14,3 +14,4 @@
@import "./ordered-list.css";
@import "./highlight.css";
@import "./columns.css";
@import "./status.css";
@@ -0,0 +1,17 @@
.node-status {
margin-right: 2px;
&.ProseMirror-selectednode {
outline: none;
}
&.selection,
& *::selection {
background-color: transparent;
}
&.ProseMirror-selectednode .status-badge {
box-shadow: 0 0 0 2px light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-7));
border-radius: 3px;
}
}