mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
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:
@@ -56,8 +56,11 @@ export default function MathBlockView(props: NodeViewProps) {
|
|||||||
}, [debouncedPreview]);
|
}, [debouncedPreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditing(!!props.selected);
|
const pos = getPos();
|
||||||
if (props.selected) setPreview(node.attrs.text);
|
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]);
|
}, [props.selected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -46,8 +46,11 @@ export default function MathInlineView(props: NodeViewProps) {
|
|||||||
}, [preview, isEditing]);
|
}, [preview, isEditing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditing(!!props.selected);
|
const pos = getPos();
|
||||||
if (props.selected) setPreview(node.attrs.text);
|
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]);
|
}, [props.selected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
IconSitemap,
|
IconSitemap,
|
||||||
IconColumns3,
|
IconColumns3,
|
||||||
IconColumns2,
|
IconColumns2,
|
||||||
|
IconTag,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
@@ -385,6 +386,20 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.run();
|
.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)",
|
title: "Subpages (Child pages)",
|
||||||
description: "List all subpages of the current page",
|
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,
|
SharedStorage,
|
||||||
Columns,
|
Columns,
|
||||||
Column,
|
Column,
|
||||||
|
Status
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
userColors,
|
userColors,
|
||||||
} from "@/features/editor/extensions/utils.ts";
|
} from "@/features/editor/extensions/utils.ts";
|
||||||
import { IUser } from "@/features/user/types/user.types.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 {
|
import {
|
||||||
createImageHandle,
|
createImageHandle,
|
||||||
imageResizeClasses,
|
imageResizeClasses,
|
||||||
@@ -64,7 +62,11 @@ import {
|
|||||||
createResizeHandle,
|
createResizeHandle,
|
||||||
buildResizeClasses,
|
buildResizeClasses,
|
||||||
} from "@/features/editor/components/common/node-resize-handles.ts";
|
} 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 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 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";
|
||||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||||
@@ -309,6 +311,9 @@ export const mainExtensions = [
|
|||||||
Subpages.configure({
|
Subpages.configure({
|
||||||
view: SubpagesView,
|
view: SubpagesView,
|
||||||
}),
|
}),
|
||||||
|
Status.configure({
|
||||||
|
view: StatusView,
|
||||||
|
}),
|
||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -14,3 +14,4 @@
|
|||||||
@import "./ordered-list.css";
|
@import "./ordered-list.css";
|
||||||
@import "./highlight.css";
|
@import "./highlight.css";
|
||||||
@import "./columns.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
UniqueID,
|
UniqueID,
|
||||||
Columns,
|
Columns,
|
||||||
Column,
|
Column,
|
||||||
|
Status,
|
||||||
addUniqueIdsToDoc,
|
addUniqueIdsToDoc,
|
||||||
htmlToMarkdown,
|
htmlToMarkdown,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
@@ -95,6 +96,7 @@ export const tiptapExtensions = [
|
|||||||
Subpages,
|
Subpages,
|
||||||
Columns,
|
Columns,
|
||||||
Column,
|
Column,
|
||||||
|
Status,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: any) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ 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";
|
export * from "./lib/columns";
|
||||||
|
export * from "./lib/status";
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Node } from '@tiptap/core';
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
|
||||||
|
export type StatusStorage = {
|
||||||
|
autoOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
status: {
|
||||||
|
setStatus: (attributes?: { text?: string; color?: string }) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Storage {
|
||||||
|
status: StatusStorage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatusColor =
|
||||||
|
| 'gray'
|
||||||
|
| 'blue'
|
||||||
|
| 'green'
|
||||||
|
| 'yellow'
|
||||||
|
| 'red'
|
||||||
|
| 'purple';
|
||||||
|
|
||||||
|
export interface StatusOption {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
view: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Status = Node.create<StatusOption, StatusStorage>({
|
||||||
|
name: 'status',
|
||||||
|
group: 'inline',
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
view: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addStorage() {
|
||||||
|
return {
|
||||||
|
autoOpen: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
text: {
|
||||||
|
default: '',
|
||||||
|
parseHTML: (element: HTMLElement) => element.textContent || '',
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
default: 'gray',
|
||||||
|
parseHTML: (element: HTMLElement) =>
|
||||||
|
element.getAttribute('data-color') || 'gray',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `span[data-type="${this.name}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
'data-type': this.name,
|
||||||
|
'data-color': HTMLAttributes.color,
|
||||||
|
},
|
||||||
|
HTMLAttributes.text,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setStatus:
|
||||||
|
(attributes) =>
|
||||||
|
({ commands }) => {
|
||||||
|
this.storage.autoOpen = true;
|
||||||
|
return commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: {
|
||||||
|
text: attributes?.text ?? '',
|
||||||
|
color: attributes?.color || 'gray',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user