diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 43447f8c..9c0bb37d 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -217,6 +217,7 @@ import { EditableImageGallery } from "./editor/ImageGallery"; import { ImageViewer } from "./editor/ImageViewer"; import { PdfAttachment } from "./editor/PdfAttachment"; import { FileAttachment } from "./editor/FileAttachment"; +import { createInlineFieldExtension } from "./editor/InlineField"; import { createPluginEmbedExtension } from "./editor/PluginEmbed"; import { getEditorScrollProgress, restoreEditorScrollProgress } from "./editor/editor-mode-scroll"; import { useEditorSaveStatus } from "./editor/useEditorSaveStatus"; @@ -1181,6 +1182,7 @@ const RichEditorPane = ({ }, [queryClient, repository, resourceInsertionLimit, t]); const pluginEmbedExtension = useMemo(() => createPluginEmbedExtension(pluginHost), [pluginHost]); + const inlineFieldExtension = useMemo(() => createInlineFieldExtension(i18n.language), [i18n.language]); const editor = useEditor({ extensions: [ StarterKit.configure({ @@ -1190,6 +1192,7 @@ const RichEditorPane = ({ EdgeEverLink.configure({ openOnClick: false }), TaskList, TaskItem.configure({ nested: true }), + inlineFieldExtension, EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }), MergeDivider, pluginEmbedExtension, diff --git a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx index cbf722d9..4fedc5d7 100644 --- a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx +++ b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx @@ -14,6 +14,7 @@ import { createExcerpt, docToMarkdown, docToText, emptyDoc, getImageReferrerPoli import { createEdgeEverMathematics } from "@edgeever/shared/mathematics"; import { getMobileEditorInputAttributes, getMobileEditorPlaceholder } from "@edgeever/shared/mobile-editor"; import { EdgeEverLink } from "@edgeever/shared/editor-link"; +import { createInlineFieldExtension } from "@/components/editor/InlineField"; import { MobileEditorFallback, MobileEditorHeader, @@ -195,6 +196,7 @@ export const MobileStandaloneTiptapEditor = ({ }; }, [memoId, readLocalDraft]); + const inlineFieldExtension = useMemo(() => createInlineFieldExtension(locale), [locale]); const editor = useEditor({ extensions: [ StarterKit.configure({ link: false }), @@ -203,6 +205,7 @@ export const MobileStandaloneTiptapEditor = ({ FileAttachment, TaskList, TaskItem.configure({ nested: true }), + inlineFieldExtension, MergeDivider, PluginEmbed, ...createEdgeEverMathematics(), diff --git a/apps/web/src/components/editor/InlineField.ts b/apps/web/src/components/editor/InlineField.ts new file mode 100644 index 00000000..bc715d79 --- /dev/null +++ b/apps/web/src/components/editor/InlineField.ts @@ -0,0 +1,56 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey, TextSelection, type EditorState } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { + findInlineFields, + formatInlineFieldChip, + languageFromLocale, + rangesOverlap, + type InlineFieldLanguage, +} from "@/lib/inline-fields"; + +const INLINE_FIELD_PLUGIN_KEY = new PluginKey("edgeever-inline-field"); + +const collectInlineFieldDecorations = (state: EditorState, language: InlineFieldLanguage) => { + const decorations: Decoration[] = []; + const cursor = state.selection.from; + state.doc.descendants((node, pos) => { + if (node.type.spec.code) return false; + if (!node.isText || !node.text) return; + if (node.marks.some((mark) => mark.type.spec.code)) return; + for (const field of findInlineFields(node.text)) { + const from = pos + field.from; + const to = pos + field.to; + if (rangesOverlap(from, to, cursor) || rangesOverlap(from, to, state.selection.to)) continue; + decorations.push(Decoration.inline(from, to, { class: "ee-inline-field-source" })); + decorations.push(Decoration.widget(from, (view) => { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "ee-inline-field"; + chip.textContent = formatInlineFieldChip(field.key, field.value, language); + chip.addEventListener("mousedown", (event) => { + event.preventDefault(); + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, from + 1))); + view.focus(); + }); + return chip; + }, { side: -1, key: `${from}:${field.key}` })); + } + }); + return decorations.length ? DecorationSet.create(state.doc, decorations) : DecorationSet.empty; +}; + +export const createInlineFieldExtension = (locale: string) => Extension.create({ + name: "inlineField", + addProseMirrorPlugins() { + const language = languageFromLocale(locale); + return [ + new Plugin({ + key: INLINE_FIELD_PLUGIN_KEY, + props: { + decorations: (state) => collectInlineFieldDecorations(state, language), + }, + }), + ]; + }, +}); diff --git a/apps/web/src/lib/inline-fields.test.mjs b/apps/web/src/lib/inline-fields.test.mjs new file mode 100644 index 00000000..18138177 --- /dev/null +++ b/apps/web/src/lib/inline-fields.test.mjs @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { findInlineFields, formatInlineFieldChip, languageFromLocale, rangesOverlap } from "./inline-fields.ts"; + +describe("inline fields", () => { + test("finds Dataview bracket fields in a task line", () => { + const text = "审阅面板 [priority:: highest] [scheduled:: 2026-09-10] [due:: 2026-09-10]"; + const fields = findInlineFields(text); + expect(fields.map((field) => field.key)).toEqual(["priority", "scheduled", "due"]); + expect(fields.map((field) => field.value)).toEqual(["highest", "2026-09-10", "2026-09-10"]); + expect(text.slice(fields[0].from, fields[0].to)).toBe("[priority:: highest]"); + }); + + test("formats quiet chips instead of raw syntax", () => { + expect(formatInlineFieldChip("due", "2026-09-10", "zh")).toBe("截止 2026-09-10"); + expect(formatInlineFieldChip("priority", "highest", "zh")).toBe("优先级 最高"); + expect(formatInlineFieldChip("due", "2026-09-10", "en")).toBe("Due 2026-09-10"); + expect(languageFromLocale("zh-CN")).toBe("zh"); + }); + + test("keeps the raw field editable when the caret is inside it", () => { + expect(rangesOverlap(5, 27, 10)).toBe(true); + expect(rangesOverlap(5, 27, 27)).toBe(true); + expect(rangesOverlap(5, 27, 4)).toBe(false); + }); +}); diff --git a/apps/web/src/lib/inline-fields.ts b/apps/web/src/lib/inline-fields.ts new file mode 100644 index 00000000..af25a131 --- /dev/null +++ b/apps/web/src/lib/inline-fields.ts @@ -0,0 +1,60 @@ +export type InlineFieldLanguage = "en" | "zh"; + +export type InlineFieldMatch = { + from: number; + to: number; + key: string; + value: string; +}; + +const FIELD_LABELS: Record> = { + due: { en: "Due", zh: "截止" }, + scheduled: { en: "Scheduled", zh: "计划" }, + start: { en: "Start", zh: "开始" }, + completion: { en: "Done", zh: "完成" }, + created: { en: "Created", zh: "创建" }, + cancelled: { en: "Cancelled", zh: "取消" }, + priority: { en: "Priority", zh: "优先级" }, + repeat: { en: "Repeat", zh: "重复" }, + id: { en: "ID", zh: "ID" }, + dependsOn: { en: "Depends", zh: "依赖" }, + onCompletion: { en: "On completion", zh: "完成后" }, +}; + +const PRIORITY_VALUES: Record> = { + highest: { en: "Highest", zh: "最高" }, + high: { en: "High", zh: "高" }, + medium: { en: "Medium", zh: "中" }, + low: { en: "Low", zh: "低" }, + lowest: { en: "Lowest", zh: "最低" }, +}; + +export const INLINE_FIELD_PATTERN = /\[([A-Za-z][A-Za-z0-9_-]*)::\s*([^\]]+?)\]/g; + +export const languageFromLocale = (locale: string): InlineFieldLanguage => + locale.toLowerCase().startsWith("zh") ? "zh" : "en"; + +export const findInlineFields = (text: string): InlineFieldMatch[] => { + const fields: InlineFieldMatch[] = []; + const pattern = new RegExp(INLINE_FIELD_PATTERN.source, "g"); + for (const match of text.matchAll(pattern)) { + if (match.index === undefined) continue; + fields.push({ + from: match.index, + to: match.index + match[0].length, + key: match[1], + value: match[2].trim(), + }); + } + return fields; +}; + +export const formatInlineFieldChip = (key: string, value: string, language: InlineFieldLanguage) => { + const label = FIELD_LABELS[key]?.[language] ?? key; + const displayValue = key === "priority" + ? (PRIORITY_VALUES[value.toLowerCase()]?.[language] ?? value) + : value; + return `${label} ${displayValue}`; +}; + +export const rangesOverlap = (from: number, to: number, cursor: number) => cursor >= from && cursor <= to; diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 860b525d..047ce668 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -1022,6 +1022,33 @@ textarea { padding-left: 1.5rem; } +.ProseMirror .ee-inline-field-source { + font-size: 0; + color: transparent; +} + +.ProseMirror .ee-inline-field { + display: inline-flex; + align-items: center; + margin: 0 0.15rem; + padding: 0 0.45rem; + border: 1px solid rgb(var(--slate-200-rgb, 226 232 240)); + border-radius: 999px; + background: rgb(var(--slate-100-rgb, 241 245 249)); + color: rgb(var(--slate-500-rgb, 100 116 139)); + font-size: 0.75rem; + font-weight: 600; + line-height: 1.55; + vertical-align: 0.12em; + cursor: text; +} + +:root.dark .ProseMirror .ee-inline-field { + border-color: rgb(51 65 85); + background: rgb(30 41 59); + color: rgb(148 163 184); +} + .ProseMirror blockquote { margin: 0 0 1rem; border-left: 3px solid var(--brand-green); diff --git a/apps/web/src/styles/mobile-markdown-editor.css b/apps/web/src/styles/mobile-markdown-editor.css index 6510ca4f..c660814d 100644 --- a/apps/web/src/styles/mobile-markdown-editor.css +++ b/apps/web/src/styles/mobile-markdown-editor.css @@ -607,6 +607,26 @@ html.dark .edgeever-mobile-tiptap-content a:hover { padding-left: 24px; } +.edgeever-mobile-tiptap-content .ee-inline-field-source { + font-size: 0; + color: transparent; +} + +.edgeever-mobile-tiptap-content .ee-inline-field { + display: inline-flex; + align-items: center; + margin: 0 0.15rem; + padding: 0 0.45rem; + border: 1px solid #e2e8f0; + border-radius: 999px; + background: #f1f5f9; + color: #64748b; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.55; + vertical-align: 0.12em; +} + .edgeever-mobile-tiptap-content blockquote { border-left: 3px solid #16a06e; border-radius: 1px 4px 4px 1px;