diff --git a/apps/client/src/features/editor/components/math/math-block.tsx b/apps/client/src/features/editor/components/math/math-block.tsx index a289fcab..014b9a6f 100644 --- a/apps/client/src/features/editor/components/math/math-block.tsx +++ b/apps/client/src/features/editor/components/math/math-block.tsx @@ -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 ( diff --git a/apps/client/src/features/editor/components/math/math-inline.tsx b/apps/client/src/features/editor/components/math/math-inline.tsx index 8d4897dc..c19a8e12 100644 --- a/apps/client/src/features/editor/components/math/math-inline.tsx +++ b/apps/client/src/features/editor/components/math/math-inline.tsx @@ -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 ( diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 03ba7b80..d31bdb18 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -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", diff --git a/apps/client/src/features/editor/components/status/status-view.tsx b/apps/client/src/features/editor/components/status/status-view.tsx new file mode 100644 index 00000000..c6d8da39 --- /dev/null +++ b/apps/client/src/features/editor/components/status/status-view.tsx @@ -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 = { + 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(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 ( + + { + if (!open && !text) { + deleteNode(); + return; + } + setOpened(open); + }} + width={220} + position="bottom" + withArrow + shadow="md" + trapFocus + > + + isEditable && setOpened(true)} + role="button" + tabIndex={0} + > + {text || "SET STATUS"} + + + + + + handleTextChange(e.currentTarget.value.toUpperCase()) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + setOpened(false); + } + }} + placeholder="Status text" + size="sm" + mb="xs" + /> + + + {STATUS_COLORS.map(({ name, bg }) => ( + handleColorChange(name)} + > + {color === name && } + + ))} + + + + + ); +} diff --git a/apps/client/src/features/editor/components/status/status.module.css b/apps/client/src/features/editor/components/status/status.module.css new file mode 100644 index 00000000..a9b2ae2b --- /dev/null +++ b/apps/client/src/features/editor/components/status/status.module.css @@ -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)); +} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 9610e791..22c595e0 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -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, }), diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index 120c2a10..7ec4be9b 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -14,3 +14,4 @@ @import "./ordered-list.css"; @import "./highlight.css"; @import "./columns.css"; +@import "./status.css"; diff --git a/apps/client/src/features/editor/styles/status.css b/apps/client/src/features/editor/styles/status.css new file mode 100644 index 00000000..99b983f5 --- /dev/null +++ b/apps/client/src/features/editor/styles/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; + } +} diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index d0cb2d9e..9fa2f7a6 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -35,6 +35,7 @@ import { UniqueID, Columns, Column, + Status, addUniqueIdsToDoc, htmlToMarkdown, } from '@docmost/editor-ext'; @@ -95,6 +96,7 @@ export const tiptapExtensions = [ Subpages, Columns, Column, + Status, ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index feb7e488..e8ea4311 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -26,3 +26,4 @@ export * from "./lib/unique-id"; export * from "./lib/shared-storage"; export * from "./lib/recreate-transform"; export * from "./lib/columns"; +export * from "./lib/status"; diff --git a/packages/editor-ext/src/lib/status.ts b/packages/editor-ext/src/lib/status.ts new file mode 100644 index 00000000..04a34204 --- /dev/null +++ b/packages/editor-ext/src/lib/status.ts @@ -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 { + 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; + view: any; +} + +export const Status = Node.create({ + 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', + }, + }); + }, + }; + }, +});