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:
1 parent
7b5134be8c
commit
a10267b0d6
7 files changed
+194
No files matched your search
@@ -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),
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user