feat(web): render Dataview inline fields as quiet editor chips

Keep [due:: YYYY-MM-DD] in the Markdown, but decorate the live editor
with compact labels. Clicking a chip reveals the raw field to edit.
This commit is contained in:
tianma-if committed 2026-09-12 00:05:24 +08:00
1 parent 7b5134be8c
commit a10267b0d6
7 files changed
+194

No files matched your search

+3
View File
@@ -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,
@@ -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(),
@@ -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),
},
}),
];
},
});
+25
View File
@@ -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);
});
});
+60
View File
@@ -0,0 +1,60 @@
export type InlineFieldLanguage = "en" | "zh";
export type InlineFieldMatch = {
from: number;
to: number;
key: string;
value: string;
};
const FIELD_LABELS: Record<string, Record<InlineFieldLanguage, string>> = {
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<string, Record<InlineFieldLanguage, string>> = {
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;
+27
View File
@@ -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);
@@ -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;