feat(web): add a beta structured table note

Store typed records in the note body and edit them as a grid on web and desktop. Phones and iOS stay read-only so the record model is not overwritten.
This commit is contained in:
tianma-if committed 2026-09-23 00:00:44 +08:00
1 parent 41fc5825eb
commit c2a26b23ac
27 files changed
+1530 -33

No files matched your search

+21 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { globSync, readFileSync } from "node:fs";
import { Database } from "bun:sqlite";
import { callMcpTool } from "./index.ts";
import { parseDiagramDocument } from "@edgeever/shared";
import { parseDiagramDocument, parseTableDocument, serializeTableDocument, createDefaultTableDocument } from "@edgeever/shared";
class SqliteD1PreparedStatement {
constructor(db, sql, bindings = []) {
@@ -198,6 +198,26 @@ describe("MCP template and AI instruction management", () => {
expect(parseDiagramDocument(stored.content_markdown).nodes.map((node) => node.id)).toContain("redis");
});
test("refuses to replace a structured table through update_memo", async () => {
const { sqlite, auth, context } = createFixture();
sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)")
.run("nb_tables", "ws_mcp", "Tables");
const created = await callMcpTool(context, auth, "create_memo", {
notebookId: "nb_tables",
title: "阅读清单",
contentMarkdown: serializeTableDocument(createDefaultTableDocument()),
});
const read = await callMcpTool(context, auth, "get_memo", { memoId: created.memo.id });
expect(read.structuredTable).toMatchObject({ fieldCount: 3, recordCount: 1 });
expect(JSON.stringify(read)).not.toContain("edgeever-table-v1");
await expect(callMcpTool(context, auth, "update_memo", {
memoId: created.memo.id,
contentMarkdown: "plain text",
})).rejects.toMatchObject({ code: "table_update_required" });
const stored = sqlite.query("SELECT content_markdown FROM memo_contents WHERE memo_id = ?").get(created.memo.id);
expect(parseTableDocument(stored.content_markdown)?.records).toHaveLength(1);
});
test("keeps mind-map hierarchy edges consistent when a node is reparented", async () => {
const { sqlite, auth, context } = createFixture();
sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)")
+21 -3
View File
@@ -6,9 +6,13 @@ import {
TemplateUpdateSchema,
docToText,
markdownToDoc,
getTableSummary,
hasTableDocumentMarker,
parseDiagramDocument,
parseTableDocument,
serializeDiagramDocument,
stripDiagramDocumentMarker,
stripTableDocumentMarker,
type DiagramDocument,
type DiagramNodeShape,
type MemoDetail,
@@ -383,6 +387,12 @@ const memoWithoutDiagramPayload = (memo: MemoDetail) => {
return { ...memo, contentMarkdown, contentJson, contentText: docToText(contentJson) };
};
const memoWithoutTablePayload = (memo: MemoDetail) => {
const contentMarkdown = stripTableDocumentMarker(memo.contentMarkdown);
const contentJson = markdownToDoc(contentMarkdown);
return { ...memo, contentMarkdown, contentJson, contentText: docToText(contentJson) };
};
type MutableDiagramEdge = ReturnType<typeof diagramSemanticGraph>["edges"][number];
const applyDiagramOperations = async (
@@ -683,9 +693,10 @@ export const callMcpTool = async (
}
const diagram = parseDiagramDocument(memo.contentMarkdown);
return diagram
? { memo: memoWithoutDiagramPayload(memo), diagram: diagramSemanticGraph(diagram) }
: { memo };
if (diagram) return { memo: memoWithoutDiagramPayload(memo), diagram: diagramSemanticGraph(diagram) };
const table = parseTableDocument(memo.contentMarkdown);
if (table) return { memo: memoWithoutTablePayload(memo), structuredTable: getTableSummary(memo.contentMarkdown).tablePreview };
return { memo };
}
case "create_memo": {
assertScope(auth, "write:memos");
@@ -796,6 +807,13 @@ export const callMcpTool = async (
400,
);
}
if (existing && hasTableDocumentMarker(existing.contentMarkdown)) {
throw new AppError(
"table_update_required",
"Structured table content cannot be replaced through update_memo.",
400,
);
}
}
const actor = getAuditActor(c);
const actorLabel = getActorLabel(c);
+2
View File
@@ -4,6 +4,7 @@ import {
docToText,
markdownToDoc,
getDiagramSummary,
getTableSummary,
type MemoSummary,
} from "@edgeever/shared";
import { parseJsonArray } from "./entity-utils";
@@ -70,6 +71,7 @@ export const mapMemoSummary = (row: MemoSummaryRow): MemoSummary => ({
createExcerpt(row.content_text ?? "") ||
createExcerpt(docToText(markdownToDoc(row.content_markdown ?? ""))),
...getDiagramSummary(row.content_markdown),
...getTableSummary(row.content_markdown),
tags: parseJsonArray(row.tags_json),
isPinned: Boolean(row.is_pinned),
isArchived: Boolean(row.is_archived),
@@ -26,6 +26,12 @@ enum TipTapContentSource: Sendable {
if markdown.contains("<!-- edgeever-diagram-v1:") {
return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)")
}
// Structured tables keep their JSON record model in the same comment.
// The viewer shows the readable Markdown table and never the marker.
if markdown.contains("<!-- edgeever-table-v1:") {
let stripped = stripStructuredTableMarker(markdown)
return Decision(useJSON: false, payload: stripped, fingerprint: "table:\(stripped)")
}
if jsonUsable {
if jsonHasImageWidth(json) {
return Decision(useJSON: true, payload: documentJSON, fingerprint: "json:\(json)")
@@ -53,6 +59,14 @@ enum TipTapContentSource: Sendable {
return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)")
}
static func stripStructuredTableMarker(_ markdown: String) -> String {
markdown.replacingOccurrences(
of: #"<!--\s*edgeever-table-v1:[\s\S]*?-->"#,
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespacesAndNewlines)
}
/// True when TipTap JSON stores at least one image `width` (25–100 display size).
static func jsonHasImageWidth(_ json: String) -> Bool {
// Match `"width":50` / `"width": 50` near image nodes without full parse.
@@ -76,7 +76,7 @@ struct MemoDetailView: View {
.background(AppTheme.card)
// UIKit FAB in overlay — SwiftUI Button over WKWebView often receives zero taps.
.overlay(alignment: .bottomTrailing) {
if let memo, !memo.isDeleted, !isVisualDiagram(memo) {
if let memo, !memo.isDeleted, !blocksRichTextEdit(memo) {
EditFabButton(
accessibilityLabel: env.preferences.t("编辑笔记", en: "Edit note")
) {
@@ -316,10 +316,10 @@ struct MemoDetailView: View {
titleVisibility: .visible
) {
if let memo {
if !isVisualDiagram(memo) {
if !blocksRichTextEdit(memo) {
Button(env.preferences.t("编辑", en: "Edit")) { onEdit(memo.id, .body) }
}
if !memo.isDeleted && !isTemporaryMemoId(memo.id) && !isVisualDiagram(memo) {
if !memo.isDeleted && !isTemporaryMemoId(memo.id) && !blocksRichTextEdit(memo) {
Button(env.preferences.t("AI 笔记助手", en: "AI note assistant")) {
showAiAssistant = true
}
@@ -778,7 +778,7 @@ struct MemoDetailView: View {
onImagePreview: { source, alt in
imagePreview = (source, alt)
},
onDoubleTap: isVisualDiagram(memo) ? nil : {
onDoubleTap: blocksRichTextEdit(memo) ? nil : {
onEdit(memo.id, .body)
},
onPickImage: nil,
@@ -898,11 +898,15 @@ struct MemoDetailView: View {
.multilineTextAlignment(.leading)
}
.buttonStyle(.plain)
.disabled(memo.isDeleted || isVisualDiagram(memo))
.accessibilityLabel(isVisualDiagram(memo)
.disabled(memo.isDeleted || blocksRichTextEdit(memo))
.accessibilityLabel(isStructuredTable(memo)
? env.preferences.t("表格标题", en: "Database title")
: isVisualDiagram(memo)
? env.preferences.t("图表标题", en: "Diagram title")
: env.preferences.t("编辑笔记标题", en: "Edit note title"))
.accessibilityHint(isVisualDiagram(memo)
.accessibilityHint(isStructuredTable(memo)
? env.preferences.t("多维表格请在 Web 或桌面端编辑", en: "Edit databases on Web or desktop")
: isVisualDiagram(memo)
? env.preferences.t("可视化图表请在 Web 或桌面端编辑", en: "Edit visual diagrams on Web or desktop")
: env.preferences.t("进入编辑并聚焦标题", en: "Opens editing with the title focused"))
.accessibilityIdentifier(DetailMemoChrome.title)
@@ -925,6 +929,14 @@ struct MemoDetailView: View {
memo.contentMarkdown.contains("<!-- edgeever-diagram-v1:")
}
private func isStructuredTable(_ memo: MemoDetail) -> Bool {
memo.contentMarkdown.contains("<!-- edgeever-table-v1:")
}
private func blocksRichTextEdit(_ memo: MemoDetail) -> Bool {
isVisualDiagram(memo) || isStructuredTable(memo)
}
private func refreshSyncStatus() {
guard let scope = env.session.dataScope else {
outboxStatus = nil
@@ -13,6 +13,18 @@ final class TipTapContentSourceTests: XCTestCase {
XCTAssertTrue(decision.payload.contains("edgeever-diagram-v1"))
}
func testViewerStripsStructuredTableMarker() {
let markdown = "| 名称 |\n| --- |\n| 示例 |\n\n<!-- edgeever-table-v1:abc -->"
let decision = TipTapContentSource.resolve(
mode: .viewer,
documentJSON: #"{"type":"doc","content":[{"type":"paragraph"}]}"#,
markdown: markdown
)
XCTAssertFalse(decision.useJSON)
XCTAssertFalse(decision.payload.contains("edgeever-table-v1"))
XCTAssertTrue(decision.payload.contains("示例"))
}
func testEditorMarkdownRemainsAuthoritativeForRichStructures() {
let markdown = """
1. first
+13 -2
View File
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "bun:test";
import { createDefaultDiagramDocument, diagramFallbackMarkdown, serializeDiagramDocument } from "@edgeever/shared";
import { getMobileVisualDiagramKind, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "./mobile-diagram";
import { createDefaultDiagramDocument, createDefaultTableDocument, diagramFallbackMarkdown, serializeDiagramDocument, serializeTableDocument, tableFallbackMarkdown } from "@edgeever/shared";
import { getMobileVisualDiagramKind, hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "./mobile-diagram";
const hasMermaidCodeBlock = (doc: { content?: Array<{ type?: string; attrs?: { language?: string } }> }) =>
Boolean(doc.content?.some((node) => node.type === "codeBlock" && node.attrs?.language === "mermaid"));
@@ -36,6 +36,17 @@ describe("mobile visual diagram viewer", () => {
expect(hasMermaidCodeBlock(viewerContent)).toBe(true);
});
test("shows a structured table as a readable table and keeps the record marker out of the editor", () => {
const markdown = serializeTableDocument(createDefaultTableDocument());
expect(hasMobileStructuredTable(markdown)).toBe(true);
const viewerContent = resolveMobileMemoViewerContent(null, markdown);
expect(JSON.stringify(viewerContent)).not.toContain("edgeever-table-v1");
expect(viewerContent.content?.some((node) => node.type === "table")).toBe(true);
const broken = `${tableFallbackMarkdown(createDefaultTableDocument())}\n\n<!-- edgeever-table-v1:not-json -->`;
expect(hasMobileStructuredTable(broken)).toBe(true);
expect(JSON.stringify(resolveMobileMemoViewerContent(null, broken))).not.toContain("edgeever-table-v1");
});
test("fills the remaining phone viewport instead of shrinking the canvas to a postage stamp", () => {
const source = readFileSync(new URL("../components/LocalTiptapEditor.tsx", import.meta.url), "utf8");
expect(source).toContain(".edgeever-editor-scroll:has(.edgeever-x6-document) { display: flex; flex-direction: column; overflow: hidden; }");
+12
View File
@@ -1,9 +1,13 @@
import {
hasDiagramDocumentMarker,
hasTableDocumentMarker,
markdownToDoc,
parseDiagramDocument,
parseTableDocument,
resolveMemoContentDoc,
stripDiagramDocumentMarker,
stripTableDocumentMarker,
tableFallbackMarkdown,
type TiptapDoc,
type DiagramKind,
} from "@edgeever/shared";
@@ -14,6 +18,9 @@ export const getMobileVisualDiagramKind = (contentMarkdown: string): DiagramKind
export const hasMobileVisualDiagram = (contentMarkdown: string) =>
hasDiagramDocumentMarker(contentMarkdown);
export const hasMobileStructuredTable = (contentMarkdown: string) =>
hasTableDocumentMarker(contentMarkdown);
/** Viewer TipTap payload for a visual-diagram envelope. Valid IR is drawn by read-only X6, so this returns an empty doc instead of a hidden Mermaid projection. Invalid envelopes keep the stripped Mermaid fence as degraded content. */
export const resolveMobileMemoViewerContent = (
contentJson: TiptapDoc | null | undefined,
@@ -25,5 +32,10 @@ export const resolveMobileMemoViewerContent = (
if (hasDiagramDocumentMarker(contentMarkdown)) {
return markdownToDoc(stripDiagramDocumentMarker(contentMarkdown));
}
const table = parseTableDocument(contentMarkdown);
if (table) return markdownToDoc(tableFallbackMarkdown(table));
if (hasTableDocumentMarker(contentMarkdown)) {
return markdownToDoc(stripTableDocumentMarker(contentMarkdown));
}
return resolveMemoContentDoc(contentJson, contentMarkdown);
};
@@ -18,7 +18,7 @@ import { MobileAiAssistantModal } from "../components/MobileAiAssistantModal";
import { MobileResourceActions } from "../components/MobileResourceActions";
import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom";
import { getNextMobileNoteSearchIndex } from "../lib/mobile-note-search";
import { hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "../lib/mobile-diagram";
import { hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "../lib/mobile-diagram";
import { safeDomCall } from "../lib/safe-dom-call";
import {
getMobileImageTarget,
@@ -524,6 +524,11 @@ export const MemoDetailModal = ({
() => (memo ? hasMobileVisualDiagram(memo.contentMarkdown) : false),
[memo]
);
const isStructuredTable = useMemo(
() => (memo ? hasMobileStructuredTable(memo.contentMarkdown) : false),
[memo]
);
const locksRichTextEdit = isVisualDiagram || isStructuredTable;
const visualDiagramJson = useMemo(() => {
const diagram = memo ? parseDiagramDocument(memo.contentMarkdown) : null;
return diagram ? JSON.stringify(diagram) : undefined;
@@ -1113,7 +1118,7 @@ export const MemoDetailModal = ({
</View>
) : (
<View style={detailLayoutStyles.meta}>
{!memo.isDeleted && !isVisualDiagram ? (
{!memo.isDeleted && !locksRichTextEdit ? (
<Pressable
accessibilityHint="进入编辑并聚焦标题"
accessibilityLabel="编辑笔记标题"
@@ -1232,7 +1237,7 @@ export const MemoDetailModal = ({
onAiRequest={isEditing ? editor.requestSelectionAi : undefined}
onChange={isEditing ? editor.persistDraft : undefined}
onImagePreview={isEditing ? undefined : onImagePreview}
onDoublePress={isEditing || isVisualDiagram ? undefined : async () => {
onDoublePress={isEditing || locksRichTextEdit ? undefined : async () => {
beginEditorStartup();
onRichEdit(memo, "body");
}}
@@ -1269,7 +1274,7 @@ export const MemoDetailModal = ({
<Text style={styles.errorText}>笔记加载失败</Text>
</View>
)}
{memo && !memo.isDeleted && !isVisualDiagram && !isEditing ? (
{memo && !memo.isDeleted && !locksRichTextEdit && !isEditing ? (
<Pressable
accessibilityLabel="编辑笔记"
accessibilityRole="button"
@@ -1289,7 +1294,7 @@ export const MemoDetailModal = ({
<Pressable style={styles.actionSheet}>
<View style={styles.actionSheetHandle} />
<Text style={styles.actionSheetTitle}>{resolvedLocale !== "zh-CN" ? "Note actions" : "笔记操作"}</Text>
{!memo.isDeleted && !isVisualDiagram ? (
{!memo.isDeleted && !locksRichTextEdit ? (
<DetailActionSheetItem
icon={<Sparkles color="#16A06E" size={18} />}
label={resolvedLocale !== "zh-CN" ? "AI note assistant" : "AI 笔记助手"}
+14 -1
View File
@@ -1,7 +1,7 @@
import { useRef, useState, useEffect, type DragEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import * as m from "motion/react-m";
import { GitBranch, Network, Workflow, Star, Check, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react";
import { GitBranch, Network, Table, Workflow, Star, Check, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react";
import { getMemoListTimestamp, type MemoSummary } from "@edgeever/shared";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
@@ -91,6 +91,7 @@ export const MemoCard = ({
const diagramLabel = memo.diagramKind
? t(`diagram.${memo.diagramKind === "mind-map" ? "mindMap" : memo.diagramKind}`)
: null;
const tableLabel = !diagramLabel && memo.structuredTable ? t("structuredTable.name") : null;
const DiagramIcon = memo.diagramKind === "mind-map" ? GitBranch : memo.diagramKind === "architecture" ? Network : Workflow;
const listTimestamp = getMemoListTimestamp(memo, sortMode);
const listTimestampLabel = formatMemoPreviewDate(
@@ -413,6 +414,18 @@ export const MemoCard = ({
<div className="line-clamp-2 text-[13px] leading-relaxed text-slate-600 ">{memo.diagramPreview.labels.join(" · ")}</div>
) : null}
</div>
) : tableLabel ? (
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500">
<span className="inline-flex items-center gap-1 rounded border border-slate-200 px-1.5 py-0.5">
<Table className="h-3 w-3" aria-hidden="true" />{tableLabel}
</span>
{memo.tablePreview ? <span>{t("structuredTable.listCounts", { records: memo.tablePreview.recordCount, fields: memo.tablePreview.fieldCount })}</span> : null}
</div>
{listDensity !== "compact" && memo.tablePreview?.fieldNames.length ? (
<div className="line-clamp-2 text-[13px] leading-relaxed text-slate-600">{memo.tablePreview.fieldNames.join(" · ")}</div>
) : null}
</div>
) : (
<div
className={cn(
+11 -3
View File
@@ -30,6 +30,7 @@ import {
RotateCcw,
FileText,
Network,
Table,
Workflow,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -47,7 +48,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { NotebookTreeItem } from "./NotebookTreeItem";
import { cn } from "@/lib/utils";
import type { Notebook, AuthUser, DiagramKind } from "@edgeever/shared";
import type { Notebook, AuthUser, NoteCreateKind } from "@edgeever/shared";
import type { NotebookNode, NotebookDropPosition, NotebookSortMode } from "@/lib/app-helpers";
import type { SyncQueueSummary } from "@/lib/sync-queue";
import {
@@ -300,7 +301,7 @@ const SidebarRailButton = ({
</Tooltip>
);
const CreateMemoTypeItems = ({ onCreateMemo }: { onCreateMemo: (kind?: DiagramKind) => void }) => {
const CreateMemoTypeItems = ({ onCreateMemo }: { onCreateMemo: (kind?: NoteCreateKind) => void }) => {
const { t } = useTranslation();
return (
@@ -321,6 +322,13 @@ const CreateMemoTypeItems = ({ onCreateMemo }: { onCreateMemo: (kind?: DiagramKi
<Boxes className="h-4 w-4" />
{t("diagram.architecture")}
</DropdownMenuItem>
<DropdownMenuItem className="gap-2" onSelect={() => onCreateMemo("table")}>
<Table className="h-4 w-4" />
<span className="min-w-0 flex-1 truncate">{t("structuredTable.name")}</span>
<span className="inline-flex shrink-0 items-center rounded-full border border-emerald-200/80 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-semibold leading-none tracking-wide text-emerald-700">
Beta
</span>
</DropdownMenuItem>
</>
);
};
@@ -505,7 +513,7 @@ export const NotebookPane = ({
onOpenTrash: () => void;
onEmptyTrash: () => void;
onOpenSettings: () => void;
onCreateMemo: (kind?: DiagramKind) => void;
onCreateMemo: (kind?: NoteCreateKind) => void;
canCreateMemo: boolean;
isCreatingMemo: boolean;
syncSummary: SyncQueueSummary;
+644
View File
@@ -0,0 +1,644 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { ChevronLeft, Download, Plus, Table, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
addTableField,
addTableRecord,
applyTableView,
createTableId,
markdownToDoc,
parseTableDocument,
removeTableField,
removeTableRecord,
replaceTableView,
serializeTableDocument,
TABLE_FIELD_LIMIT,
TABLE_FIELD_TYPES,
TABLE_FILTER_OPERATORS,
TABLE_RECORD_LIMIT,
tableDocumentToCsv,
tableFallbackMarkdown,
updateTableCell,
updateTableField,
type MemoDetail,
type TableCellValue,
type TableDocument,
type TableField,
type TableFieldType,
type TableFilter,
type TableFilterOperator,
type TableRecord,
} from "@edgeever/shared";
import { MemoTitleInput } from "@/components/MemoTitleInput";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import { EDITOR_LOCAL_SAVE_DELAY_MS } from "@/lib/app-helpers";
import { createLocalEditSession } from "@/components/editor/editor-pane-helpers";
import { isLocalMemoId } from "@/lib/local-mirror";
import { isBrowserOffline } from "@/lib/network-status";
import type { EdgeEverRepository } from "@/lib/repository";
import type { MemoEditSession } from "@edgeever/shared";
type TableEditorPaneProps = {
memo: MemoDetail;
repository: EdgeEverRepository;
readOnly: boolean;
onBackToList: () => void;
onSaved: (memo: MemoDetail) => Promise<void>;
};
type EditingCell = { recordId: string; fieldId: string };
const controlClassName = "h-8 rounded-md border border-slate-200 bg-card px-2 text-xs text-slate-700 outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 disabled:cursor-not-allowed disabled:opacity-50";
const snapshotOf = (title: string, document: TableDocument) => JSON.stringify({ title, document });
const optionText = (field: TableField) => (field.options ?? []).join(", ");
const parseOptionText = (value: string) => value.split(/[,,]/).map((item) => item.trim()).filter(Boolean);
const displayCell = (field: TableField, value: TableCellValue) => {
if (field.type === "checkbox") return value === true ? "true" : "false";
if (value === null || value === undefined || value === "") return "";
return String(value);
};
const FieldHeader = ({
document,
field,
readOnly,
onChange,
}: {
document: TableDocument;
field: TableField;
readOnly: boolean;
onChange: (document: TableDocument) => void;
}) => {
const { t } = useTranslation();
const [name, setName] = useState(field.name);
const [options, setOptions] = useState(optionText(field));
useEffect(() => setName(field.name), [field.name]);
useEffect(() => setOptions(optionText(field)), [field.id, field.options]);
const commitName = () => {
const next = name.trim();
if (next && next !== field.name) onChange(updateTableField(document, field.id, { name: next }));
else setName(field.name);
};
const commitOptions = () => onChange(updateTableField(document, field.id, { options: parseOptionText(options) }));
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex w-full min-w-36 items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-xs font-medium text-slate-600 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70"
aria-label={t("structuredTable.editField", { name: field.name })}
disabled={readOnly}
>
<span className="truncate">{field.name}</span>
<span className="shrink-0 text-[10px] text-slate-400">{t(`structuredTable.types.${field.type}`)}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 space-y-2 p-2">
<label className="block space-y-1 text-xs text-slate-500">
<span>{t("structuredTable.fieldName")}</span>
<Input value={name} onChange={(event) => setName(event.target.value)} onBlur={commitName} aria-label={t("structuredTable.fieldName")} />
</label>
<label className="block space-y-1 text-xs text-slate-500">
<span>{t("structuredTable.fieldType")}</span>
<select
className={`${controlClassName} w-full`}
value={field.type}
aria-label={t("structuredTable.fieldType")}
onChange={(event) => onChange(updateTableField(document, field.id, { type: event.target.value as TableFieldType }))}
>
{TABLE_FIELD_TYPES.map((type) => <option key={type} value={type}>{t(`structuredTable.types.${type}`)}</option>)}
</select>
</label>
{field.type === "select" ? (
<label className="block space-y-1 text-xs text-slate-500">
<span>{t("structuredTable.selectOptions")}</span>
<Input
value={options}
placeholder={t("structuredTable.selectOptionsHint")}
aria-label={t("structuredTable.selectOptions")}
onChange={(event) => setOptions(event.target.value)}
onBlur={commitOptions}
/>
</label>
) : null}
<DropdownMenuItem
disabled={document.fields.length <= 1}
onSelect={() => onChange(removeTableField(document, field.id))}
>
<Trash2 className="h-4 w-4" />
{t("structuredTable.deleteField")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
const RecordCell = ({
document,
field,
record,
active,
readOnly,
onEdit,
onCommit,
onDraft,
onChange,
}: {
document: TableDocument;
field: TableField;
record: TableRecord;
active: boolean;
readOnly: boolean;
onEdit: (cell: EditingCell | null) => void;
onCommit: (value: string) => void;
onDraft: (value: string) => void;
onChange: (document: TableDocument) => void;
}) => {
const value = record.cells[field.id] ?? null;
const text = displayCell(field, value);
const [draft, setDraft] = useState(text);
useEffect(() => { if (active) setDraft(text); }, [active, text]);
if (field.type === "checkbox") {
return (
<Checkbox
checked={value === true}
disabled={readOnly}
aria-label={field.name}
onCheckedChange={(checked) => onChange(updateTableCell(document, record.id, field.id, checked === true))}
/>
);
}
if (!active) {
return (
<button
type="button"
className="block h-8 w-full min-w-36 truncate rounded-md px-2 text-left text-sm text-slate-800 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 disabled:hover:bg-transparent"
disabled={readOnly}
onClick={() => onEdit({ recordId: record.id, fieldId: field.id })}
>
{text || <span className="text-slate-300">—</span>}
</button>
);
}
if (field.type === "select") {
const options = field.options ?? [];
const choices = draft && !options.includes(draft) ? [draft, ...options] : options;
return (
<select
autoFocus
className={`${controlClassName} w-full min-w-36`}
value={draft}
aria-label={field.name}
onChange={(event) => {
setDraft(event.target.value);
onDraft(event.target.value);
onCommit(event.target.value);
}}
>
<option value="" />
{choices.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
);
}
return (
<input
autoFocus
className={`${controlClassName} w-full min-w-36`}
type={field.type === "number" ? "number" : field.type === "date" ? "date" : field.type === "url" ? "url" : "text"}
value={draft}
aria-label={field.name}
onChange={(event) => {
setDraft(event.target.value);
onDraft(event.target.value);
}}
onBlur={() => onCommit(draft)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onCommit(draft);
}
if (event.key === "Escape") onEdit(null);
}}
/>
);
};
export const TableEditorPane = ({
memo,
repository,
readOnly,
onBackToList,
onSaved,
}: TableEditorPaneProps) => {
const { t } = useTranslation();
const parsed = useMemo(() => parseTableDocument(memo.contentMarkdown), [memo.contentMarkdown]);
const [title, setTitle] = useState(memo.title ?? "");
const [document, setDocument] = useState<TableDocument | null>(parsed);
const [editing, setEditing] = useState<EditingCell | null>(null);
const draftRef = useRef("");
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveFailed, setSaveFailed] = useState(false);
const [editSessionReady, setEditSessionReady] = useState(false);
const memoRef = useRef(memo);
const titleRef = useRef(title);
const documentRef = useRef(document);
const editingRef = useRef(editing);
const editSessionRef = useRef<MemoEditSession | null>(null);
const savedSnapshotRef = useRef(parsed ? snapshotOf(memo.title ?? "", parsed) : "");
const saveRef = useRef<() => void>(() => undefined);
memoRef.current = memo;
titleRef.current = title;
documentRef.current = document;
editingRef.current = editing;
useEffect(() => {
editSessionRef.current = null;
setEditSessionReady(false);
setSaveError(null);
setSaveFailed(false);
if (readOnly || !parsed) return;
if (isLocalMemoId(memo.id) || isBrowserOffline() || window.edgeeverDesktop?.isAvailable) {
editSessionRef.current = createLocalEditSession(memo);
setEditSessionReady(true);
return;
}
let cancelled = false;
void api.createMemoEditSession(memo.id).then(({ editSession }) => {
if (!cancelled) {
editSessionRef.current = editSession;
setEditSessionReady(true);
}
}).catch(() => {
if (!cancelled) setSaveError(t("structuredTable.editSessionError"));
});
return () => { cancelled = true; };
}, [memo.id, memo.contentHash, memo.revision, parsed, readOnly, t]);
const changeDocument = (next: TableDocument) => {
setDocument(next);
documentRef.current = next;
setDirty(snapshotOf(titleRef.current, next) !== savedSnapshotRef.current);
setSaveFailed(false);
};
const commitEditing = (value: string) => {
const current = documentRef.current;
const cell = editingRef.current;
if (!current || !cell) return;
changeDocument(updateTableCell(current, cell.recordId, cell.fieldId, value));
setEditing(null);
editingRef.current = null;
};
const save = async () => {
const currentMemo = memoRef.current;
const currentDocument = documentRef.current;
const editSession = editSessionRef.current;
if (!currentDocument || !editSession || readOnly || saving) return false;
if (editingRef.current) commitEditing(draftRef.current);
const nextDocument = documentRef.current;
if (!nextDocument) return false;
const nextSnapshot = snapshotOf(titleRef.current, nextDocument);
if (savedSnapshotRef.current === nextSnapshot) {
setDirty(false);
return true;
}
setSaving(true);
setSaveError(null);
setSaveFailed(false);
try {
const result = await repository.updateMemo(currentMemo, {
expectedRevision: currentMemo.revision,
expectedContentHash: currentMemo.contentHash,
editSessionId: editSession.id,
title: titleRef.current,
contentJson: markdownToDoc(tableFallbackMarkdown(nextDocument)),
contentMarkdown: serializeTableDocument(nextDocument),
tags: currentMemo.tags,
});
memoRef.current = result.memo;
savedSnapshotRef.current = nextSnapshot;
const hasNewChanges = snapshotOf(titleRef.current, documentRef.current ?? nextDocument) !== nextSnapshot;
setDirty(hasNewChanges);
if (!hasNewChanges) await onSaved(result.memo);
return true;
} catch (error) {
setSaveFailed(true);
setSaveError(error instanceof Error ? error.message : t("structuredTable.saveError"));
return false;
} finally {
setSaving(false);
}
};
saveRef.current = () => { void save(); };
useEffect(() => {
if (readOnly || !dirty || saving || !editSessionReady || saveFailed || editing) return;
const timer = window.setTimeout(() => saveRef.current(), EDITOR_LOCAL_SAVE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [dirty, editSessionReady, editing, readOnly, saveFailed, saving]);
const visibleRecords = useMemo(() => (document ? applyTableView(document) : []), [document]);
const columns = useMemo<ColumnDef<TableRecord>[]>(() => {
if (!document) return [];
return [
...document.fields.map((field): ColumnDef<TableRecord> => ({
id: field.id,
header: () => <FieldHeader document={document} field={field} readOnly={readOnly} onChange={changeDocument} />,
cell: ({ row }) => (
<RecordCell
document={document}
field={field}
record={row.original}
active={editing?.recordId === row.original.id && editing.fieldId === field.id}
readOnly={readOnly}
onEdit={(cell) => {
editingRef.current = cell;
if (cell) draftRef.current = displayCell(field, row.original.cells[field.id] ?? null);
setEditing(cell);
}}
onCommit={commitEditing}
onDraft={(value) => { draftRef.current = value; }}
onChange={changeDocument}
/>
),
})),
{
id: "record-actions",
header: () => null,
cell: ({ row }) => (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("structuredTable.deleteRecord")}
disabled={readOnly}
onClick={() => changeDocument(removeTableRecord(document, row.original.id))}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("structuredTable.deleteRecord")}</TooltipContent>
</Tooltip>
),
},
];
}, [document, editing, readOnly, t]);
const table = useReactTable({ data: visibleRecords, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id });
if (!document) {
return (
<div className="flex h-full min-h-0 flex-col bg-card">
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2">
<Button type="button" variant="ghost" size="sm" onClick={onBackToList}>
<ChevronLeft className="h-4 w-4" />
{t("structuredTable.back")}
</Button>
</div>
<p className="p-6 text-sm text-slate-500">{t("structuredTable.unreadable")}</p>
</div>
);
}
const filtered = document.view.filters.length > 0;
const countLabel = filtered
? t("structuredTable.recordCountFiltered", { count: visibleRecords.length, total: document.records.length })
: t("structuredTable.recordCount", { count: document.records.length });
const saveLabel = saveError ? saveError : saving ? t("structuredTable.saving") : dirty ? t("structuredTable.unsaved") : editSessionReady ? t("structuredTable.saved") : "";
const fieldLimitReached = document.fields.length >= TABLE_FIELD_LIMIT;
const recordLimitReached = document.records.length >= TABLE_RECORD_LIMIT;
const exportCsv = () => {
const blob = new Blob([tableDocumentToCsv(document, visibleRecords)], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = window.document.createElement("a");
const fileName = (title.trim() || t("structuredTable.name")).replace(/[\\/:*?"<>|]/g, "-").slice(0, 80);
link.href = url;
link.download = `${fileName}.csv`;
link.click();
URL.revokeObjectURL(url);
};
const setFilter = (index: number, filter: TableFilter) => {
const filters = document.view.filters.map((item, itemIndex) => itemIndex === index ? filter : item);
changeDocument(replaceTableView(document, { ...document.view, filters }));
};
return (
<div className="flex h-full min-h-0 flex-col bg-card">
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 px-3 py-2">
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" aria-label={t("structuredTable.back")} onClick={onBackToList}>
<ChevronLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("structuredTable.back")}</TooltipContent>
</Tooltip>
<Table className="h-4 w-4 text-emerald-600" aria-hidden="true" />
<div className="min-w-0 flex-1">
<MemoTitleInput
ariaLabel={t("structuredTable.title")}
placeholder={t("structuredTable.title")}
readOnly={readOnly}
value={title}
onValueChange={(value) => {
setTitle(value);
titleRef.current = value;
if (documentRef.current) setDirty(snapshotOf(value, documentRef.current) !== savedSnapshotRef.current);
}}
/>
</div>
<span className="text-xs text-slate-500">{countLabel}</span>
{saveLabel ? <span className={saveError ? "text-xs text-rose-600" : "text-xs text-slate-400"}>{saveLabel}</span> : null}
<Button type="button" variant="outline" size="sm" onClick={exportCsv}>
<Download className="h-4 w-4" />
{t("structuredTable.exportCsv")}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
disabled={readOnly || fieldLimitReached}
aria-label={fieldLimitReached ? t("structuredTable.fieldLimit") : t("structuredTable.addField")}
onClick={() => changeDocument(addTableField(document, {
id: createTableId("fld"),
name: t("structuredTable.newField", { index: document.fields.length + 1 }),
type: "text",
}))}
>
<Plus className="h-4 w-4" />
{t("structuredTable.addField")}
</Button>
</TooltipTrigger>
<TooltipContent>{fieldLimitReached ? t("structuredTable.fieldLimit") : t("structuredTable.addField")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="solid"
size="sm"
disabled={readOnly || recordLimitReached}
aria-label={recordLimitReached ? t("structuredTable.recordLimit") : t("structuredTable.addRecord")}
onClick={() => changeDocument(addTableRecord(document))}
>
<Plus className="h-4 w-4" />
{t("structuredTable.addRecord")}
</Button>
</TooltipTrigger>
<TooltipContent>{recordLimitReached ? t("structuredTable.recordLimit") : t("structuredTable.addRecord")}</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-3 py-2">
<span className="text-xs text-slate-500">{t("structuredTable.filter")}</span>
{document.view.filters.map((filter, index) => {
const operatorNeedsValue = filter.operator === "contains" || filter.operator === "eq";
return (
<div key={`${filter.fieldId}-${index}`} className="flex items-center gap-1">
<select
className={controlClassName}
value={filter.fieldId}
aria-label={t("structuredTable.filter")}
disabled={readOnly}
onChange={(event) => setFilter(index, { ...filter, fieldId: event.target.value })}
>
{document.fields.map((field) => <option key={field.id} value={field.id}>{field.name}</option>)}
</select>
<select
className={controlClassName}
value={filter.operator}
aria-label={t("structuredTable.filter")}
disabled={readOnly}
onChange={(event) => setFilter(index, { ...filter, operator: event.target.value as TableFilterOperator })}
>
{TABLE_FILTER_OPERATORS.map((operator) => <option key={operator} value={operator}>{t(`structuredTable.operators.${operator}`)}</option>)}
</select>
{operatorNeedsValue ? (
<Input
className="h-8 w-36 text-xs"
value={filter.value ?? ""}
placeholder={t("structuredTable.filterValue")}
aria-label={t("structuredTable.filterValue")}
disabled={readOnly}
onChange={(event) => setFilter(index, { ...filter, value: event.target.value })}
/>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("structuredTable.clearFilters")}
disabled={readOnly}
onClick={() => changeDocument(replaceTableView(document, {
...document.view,
filters: document.view.filters.filter((_, itemIndex) => itemIndex !== index),
}))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
<Button
type="button"
variant="ghost"
size="sm"
disabled={readOnly || document.view.filters.length >= 8}
onClick={() => changeDocument(replaceTableView(document, {
...document.view,
filters: [...document.view.filters, { fieldId: document.fields[0]?.id ?? "", operator: "contains", value: "" }],
}))}
>
{t("structuredTable.addFilter")}
</Button>
<label className="ml-auto flex items-center gap-1 text-xs text-slate-500">
<span>{t("structuredTable.sort")}</span>
<select
className={controlClassName}
value={document.view.sort?.fieldId ?? ""}
aria-label={t("structuredTable.sort")}
disabled={readOnly}
onChange={(event) => changeDocument(replaceTableView(document, {
...document.view,
sort: event.target.value ? { fieldId: event.target.value, direction: document.view.sort?.direction ?? "asc" } : null,
}))}
>
<option value="">{t("structuredTable.noSort")}</option>
{document.fields.map((field) => <option key={field.id} value={field.id}>{field.name}</option>)}
</select>
<select
className={controlClassName}
value={document.view.sort?.direction ?? "asc"}
aria-label={t("structuredTable.sort")}
disabled={readOnly || !document.view.sort}
onChange={(event) => {
if (!document.view.sort) return;
changeDocument(replaceTableView(document, {
...document.view,
sort: { ...document.view.sort, direction: event.target.value === "desc" ? "desc" : "asc" },
}));
}}
>
<option value="asc">{t("structuredTable.ascending")}</option>
<option value="desc">{t("structuredTable.descending")}</option>
</select>
</label>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-max min-w-full border-collapse">
<thead className="sticky top-0 z-10 bg-card">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b border-slate-200">
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-2 py-1 text-left font-medium">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b border-slate-100">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-2 py-1 align-middle">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
{visibleRecords.length === 0 ? (
<p className="px-4 py-8 text-sm text-slate-500">{filtered ? t("structuredTable.noMatches") : t("structuredTable.empty")}</p>
) : null}
</div>
</div>
);
};
export default TableEditorPane;
+46 -5
View File
@@ -46,7 +46,7 @@ import {
} from "@/lib/mobile-editor";
import { cn } from "@/lib/utils";
import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status";
import { createDefaultDiagramDocument, diagramFallbackMarkdown, getNotebookDescendantIds, markdownToDoc, parseDiagramDocument, serializeDiagramDocument, type DiagramKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { createDefaultDiagramDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookDescendantIds, markdownToDoc, parseDiagramDocument, parseTableDocument, serializeDiagramDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui";
import type {
Pane,
@@ -139,6 +139,7 @@ import { findMatchingMemoResource } from "@/lib/staged-resource-repair";
const EditorPane = lazy(() => import("./EditorPane").then((module) => ({ default: module.EditorPane })));
const DiagramEditorPane = lazy(() => import("./DiagramEditorPane"));
const TableEditorPane = lazy(() => import("./TableEditorPane"));
const AssetsPane = lazy(() => import("./AssetsPane").then((module) => ({ default: module.AssetsPane })));
const SettingsPane = lazy(() => import("./SettingsPane").then((module) => ({ default: module.SettingsPane })));
const PluginMarketplacePane = lazy(() => import("./PluginMarketplacePane").then((module) => ({ default: module.PluginMarketplacePane })));
@@ -1230,7 +1231,7 @@ export const WorkspaceApp = ({
const revealCreatedMemo = (memo: MemoDetail) => {
const targetNotebookId = memo.notebookId;
const isDiagram = Boolean(parseDiagramDocument(memo.contentMarkdown));
const isStructuredNote = Boolean(parseDiagramDocument(memo.contentMarkdown) || parseTableDocument(memo.contentMarkdown));
setMemoView("notebook");
setSearch("");
@@ -1252,11 +1253,11 @@ export const WorkspaceApp = ({
navigateWorkspaceHome();
setRightView("editor");
pendingCreatedMemoIdRef.current = memo.id;
setCreatedMemoEditId(isDiagram ? null : memo.id);
setCreatedMemoEditId(isStructuredNote ? null : memo.id);
setSelectedMemoId(memo.id);
setActivePane("editor");
if (!isDesktopViewport() && !isDiagram) {
if (!isDesktopViewport() && !isStructuredNote) {
openStandaloneMobileEditor(memo.id);
}
};
@@ -1618,6 +1619,7 @@ export const WorkspaceApp = ({
: null;
const selectedMemo = memoQuery.data?.memo ?? cachedSelectedMemo;
const selectedDiagram = parseDiagramDocument(selectedMemo?.contentMarkdown);
const selectedTable = parseTableDocument(selectedMemo?.contentMarkdown);
const desktopNotebookSidebarCollapsed = Boolean(isDesktop && notebookSidebarCollapsed);
const desktopFocusModeActive = Boolean(
isDesktop && desktopFocusMode && rightView === "editor" && selectedMemo && !memoSelectionModeActive
@@ -1875,7 +1877,7 @@ export const WorkspaceApp = ({
for (const payload of pending) void handleImportWeChatChat(payload);
}, [handleImportWeChatChat]);
const handleCreateMemo = (kind?: DiagramKind) => {
const handleCreateMemo = (kind?: NoteCreateKind) => {
const targetNotebookId = createMemoNotebookId;
if (!targetNotebookId || memoView === "trash") {
@@ -1892,6 +1894,25 @@ export const WorkspaceApp = ({
setTemplatesOpen(false);
setMobileBottomNavActive("home");
creatingMemoSelectionRef.current = true;
if (kind === "table") {
const table = createDefaultTableDocument({
name: t("structuredTable.defaultFields.name"),
status: t("structuredTable.defaultFields.status"),
date: t("structuredTable.defaultFields.date"),
sample: t("structuredTable.defaultFields.sample"),
notStarted: t("structuredTable.defaultFields.notStarted"),
inProgress: t("structuredTable.defaultFields.inProgress"),
done: t("structuredTable.defaultFields.done"),
});
createMemoMutation.mutate({
notebookId: targetNotebookId,
title: t("structuredTable.name"),
contentJson: markdownToDoc(tableFallbackMarkdown(table)),
contentMarkdown: serializeTableDocument(table),
tags: [],
});
return;
}
const diagram = kind ? createDefaultDiagramDocument(kind) : null;
createMemoMutation.mutate({
notebookId: targetNotebookId,
@@ -3333,6 +3354,26 @@ export const WorkspaceApp = ({
onOpenExecutionCenter={handleOpenExecutionCenter}
/>
) : selectedMemo && selectedTable ? (
<TableEditorPane
key={selectedMemo.id}
memo={selectedMemo}
repository={repository}
readOnly={memoView === "trash" || selectedMemo.isDeleted}
onBackToList={() => {
clearPendingCreatedMemo();
setActivePane("memos");
}}
onSaved={async (memo) => {
await putLocalMemo(localDataScope, memo);
cacheMemoDetail(queryClient, memo, memoView);
updateMemoSummaryInLists(queryClient, memoToSummary(memo));
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }),
queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }),
]);
}}
/>
) : (
<EditorPane
onOpenExecutionCenter={handleOpenExecutionCenter}
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Boxes, ChevronDown, ChevronRight, FileText, Home, Network, Plus, Search, UserRound, Workflow, X } from "lucide-react";
import { Boxes, ChevronDown, ChevronRight, FileText, Home, Network, Plus, Search, Table, UserRound, Workflow, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@@ -11,7 +11,7 @@ import {
DrawerTitle,
} from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import type { DiagramKind, Notebook } from "@edgeever/shared";
import type { NoteCreateKind, Notebook } from "@edgeever/shared";
import type { MobileBottomNavItem, NotebookNode } from "@/lib/app-helpers";
import {
buildNotebookTree,
@@ -58,7 +58,7 @@ export const MobileBottomNav = ({
activeItem: MobileBottomNavItem;
canCreateMemo: boolean;
isCreating: boolean;
onCreateMemo: (kind?: DiagramKind) => void;
onCreateMemo: (kind?: NoteCreateKind) => void;
onHome: () => void;
onOpenSettings: () => void;
}) => {
@@ -89,6 +89,13 @@ export const MobileBottomNav = ({
<DropdownMenuItem onSelect={() => onCreateMemo("mind-map")}><Network className="h-4 w-4" />{t("diagram.mindMap")}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onCreateMemo("flowchart")}><Workflow className="h-4 w-4" />{t("diagram.flowchart")}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onCreateMemo("architecture")}><Boxes className="h-4 w-4" />{t("diagram.architecture")}</DropdownMenuItem>
<DropdownMenuItem className="gap-2" onSelect={() => onCreateMemo("table")}>
<Table className="h-4 w-4" />
<span className="min-w-0 flex-1 truncate">{t("structuredTable.name")}</span>
<span className="inline-flex shrink-0 items-center rounded-full border border-emerald-200/80 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-semibold leading-none tracking-wide text-emerald-700">
Beta
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MobileBottomNavButton active={activeItem === "settings"} icon={<UserRound className="h-5 w-5" />} label={t("nav.mine")} onClick={onOpenSettings} />
+2
View File
@@ -7,6 +7,7 @@ import {
markdownToDoc,
mergeMemoDocs,
getDiagramSummary,
getTableSummary,
getNotebookDescendantIds,
resolveMemoContentDoc,
resolveMergedMemoTitle,
@@ -77,6 +78,7 @@ const toSummary = (memo: MemoDetail): MemoSummary => ({
title: memo.title,
excerpt: memo.excerpt,
...getDiagramSummary(memo.contentMarkdown),
...getTableSummary(memo.contentMarkdown),
tags: memo.tags,
isPinned: memo.isPinned,
isArchived: memo.isArchived,
@@ -1,9 +1,12 @@
import { describe, expect, test } from "bun:test";
import {
createDefaultDiagramDocument,
createDefaultTableDocument,
diagramFallbackMarkdown,
markdownToDoc,
serializeDiagramDocument,
serializeTableDocument,
tableFallbackMarkdown,
} from "@edgeever/shared";
import { resolvePublicShareBody } from "./public-share-body";
@@ -37,6 +40,26 @@ describe("public share body", () => {
}
});
test("renders a structured table note as the readable markdown table", () => {
const document = createDefaultTableDocument();
const body = resolvePublicShareBody(shareOf(serializeTableDocument(document)), "share-token");
expect(body.type).toBe("rich-text");
if (body.type === "rich-text") {
expect(JSON.stringify(body.content)).not.toContain("edgeever-table-v1");
expect(body.content.content?.some((node) => node.type === "table")).toBe(true);
}
});
test("falls back to the readable table when the structured-table envelope cannot be parsed", () => {
const markdown = `${tableFallbackMarkdown(createDefaultTableDocument())}\n\n<!-- edgeever-table-v1:not-json -->`;
const body = resolvePublicShareBody(shareOf(markdown), "share-token");
expect(body.type).toBe("rich-text");
if (body.type === "rich-text") {
expect(JSON.stringify(body.content)).not.toContain("edgeever-table-v1");
expect(body.content.content?.some((node) => node.type === "table")).toBe(true);
}
});
test("falls back to the Mermaid fence when the visual-diagram IR cannot be parsed", () => {
const markdown = `${diagramFallbackMarkdown(createDefaultDiagramDocument("mind-map"))}\n\n<!-- edgeever-diagram-v1:not-json -->`;
const body = resolvePublicShareBody(shareOf(markdown), "share-token");
+10 -2
View File
@@ -1,10 +1,14 @@
import {
hasDiagramDocumentMarker,
hasTableDocumentMarker,
markdownToDoc,
parseDiagramDocument,
parseTableDocument,
resolveMemoContentDoc,
rewriteMemoResourcesForShare,
stripDiagramDocumentMarker,
stripTableDocumentMarker,
tableFallbackMarkdown,
type DiagramDocument,
type PublicMemoShare,
type TiptapDoc,
@@ -18,8 +22,12 @@ export type PublicShareBody =
export const resolvePublicShareBody = (share: PublicMemoShare, token: string): PublicShareBody => {
const diagram = parseDiagramDocument(share.contentMarkdown);
if (diagram) return { type: "diagram", diagram };
const richText = hasDiagramDocumentMarker(share.contentMarkdown)
? markdownToDoc(stripDiagramDocumentMarker(share.contentMarkdown))
const table = parseTableDocument(share.contentMarkdown);
if (table) {
return { type: "rich-text", content: rewriteMemoResourcesForShare(markdownToDoc(tableFallbackMarkdown(table)), token, share.memoShareTokens) };
}
const richText = hasDiagramDocumentMarker(share.contentMarkdown) || hasTableDocumentMarker(share.contentMarkdown)
? markdownToDoc(hasTableDocumentMarker(share.contentMarkdown) ? stripTableDocumentMarker(share.contentMarkdown) : stripDiagramDocumentMarker(share.contentMarkdown))
: resolveMemoContentDoc(share.contentJson, share.contentMarkdown);
return {
type: "rich-text",
+2
View File
@@ -3,6 +3,7 @@ import {
createExcerpt,
docToText,
getDiagramSummary,
getTableSummary,
resolveMemoContentDoc,
type MemoDetail,
type MemoSummary,
@@ -23,6 +24,7 @@ export const memoToSummary = (memo: MemoDetail): MemoSummary => ({
title: memo.title,
excerpt: memo.excerpt || createExcerpt(memo.contentText || docToText(resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown))),
...getDiagramSummary(memo.contentMarkdown),
...getTableSummary(memo.contentMarkdown),
tags: memo.tags,
isPinned: memo.isPinned,
isArchived: memo.isArchived,
+6 -1
View File
@@ -23,6 +23,7 @@
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@tanstack/react-query": "latest",
"@tanstack/react-table": "8",
"@tanstack/react-virtual": "^3.14.13",
"@tiptap/core": "3.31.0",
"@tiptap/extension-collaboration": "3.31.0",
@@ -102,7 +103,7 @@
},
"apps/desktop": {
"name": "@edgeever/desktop",
"version": "1.79.0",
"version": "1.82.0",
"dependencies": {
"@edgeever/public-network": "workspace:*",
"croner": "^10.0.1",
@@ -1362,8 +1363,12 @@
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.101.2", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.101.2" }, "peerDependencies": { "@tanstack/react-query": "^5.101.2", "react": "^18 || ^19" } }, "sha512-19UpaRtf0lvB2QAJ2MoixxtGf3osMOd508g99EsttUj4+3eLs+ulnNgYjB7AkQMHrRlcbVXqpVNqWkB4a7g8Zw=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.13", "", { "dependencies": { "@tanstack/virtual-core": "3.17.11" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.11", "", {}, "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g=="],
"@tiptap/core": ["@tiptap/core@3.31.0", "", { "peerDependencies": { "@tiptap/pm": "3.31.0" } }, "sha512-SY3yE2ACharFvk0BKgotV6SbwV8R4tN5fGtWOM6deY4JNF3v6CXOD78RFwjs2HSohGWBRyJVPDvrX9nfi8Nsxg=="],
+1
View File
@@ -135,6 +135,7 @@
"@radix-ui/react-toggle-group": "^1.1.19",
"@radix-ui/react-tooltip": "^1.2.16",
"@tanstack/react-query": "latest",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.13",
"@tiptap/core": "3.31.0",
"@tiptap/extension-collaboration": "3.31.0",
+60
View File
@@ -275,6 +275,66 @@ export const enUS = {
createDisabled: "You cannot create a note in this view",
creating: "Creating",
},
structuredTable: {
name: "Database",
title: "Database title",
addField: "Add field",
addRecord: "Add record",
deleteField: "Delete field",
deleteRecord: "Delete this record",
fieldName: "Field name",
fieldType: "Field type",
selectOptions: "Options",
selectOptionsHint: "Separate with commas",
filter: "Filter",
addFilter: "Add filter",
clearFilters: "Clear filters",
filterValue: "Filter value",
sort: "Sort",
noSort: "No sort",
ascending: "Ascending",
descending: "Descending",
empty: "No records yet",
noMatches: "No records match the filters",
exportCsv: "Export CSV",
recordCount: "{{count}} records",
recordCountFiltered: "{{count}} / {{total}} records",
listCounts: "{{records}} records · {{fields}} fields",
newField: "Field {{index}}",
fieldLimit: "Up to 40 fields",
recordLimit: "Up to 2,000 records",
unreadable: "This database could not be read",
saveError: "Could not save the database",
editSessionError: "Could not start a safe editing session. Try again later.",
saving: "Saving",
saved: "Saved",
unsaved: "Unsaved changes",
back: "Back to note list",
editField: "Edit field {{name}}",
types: {
text: "Text",
number: "Number",
checkbox: "Checkbox",
date: "Date",
select: "Select",
url: "Link",
},
operators: {
contains: "contains",
eq: "equals",
empty: "is empty",
notEmpty: "is not empty",
},
defaultFields: {
name: "Name",
status: "Status",
date: "Date",
sample: "Sample record",
notStarted: "Not started",
inProgress: "In progress",
done: "Done",
},
},
diagram: {
normalNote: "Regular note",
mindMap: "Mind map",
+60
View File
@@ -275,6 +275,66 @@ export const ja = {
createDisabled: "この表示ではノートを作成できません",
creating: "作成中",
},
structuredTable: {
name: "データベース",
title: "データベースのタイトル",
addField: "フィールドを追加",
addRecord: "レコードを追加",
deleteField: "フィールドを削除",
deleteRecord: "このレコードを削除",
fieldName: "フィールド名",
fieldType: "フィールドの種類",
selectOptions: "選択肢",
selectOptionsHint: "カンマで区切る",
filter: "フィルタ",
addFilter: "フィルタを追加",
clearFilters: "フィルタをクリア",
filterValue: "フィルタ値",
sort: "並べ替え",
noSort: "並べ替えなし",
ascending: "昇順",
descending: "降順",
empty: "レコードはまだありません",
noMatches: "条件に一致するレコードがありません",
exportCsv: "CSV を書き出す",
recordCount: "{{count}} 件",
recordCountFiltered: "{{count}} / {{total}} 件",
listCounts: "{{records}} 件 · {{fields}} フィールド",
newField: "フィールド {{index}}",
fieldLimit: "フィールドは 40 個まで",
recordLimit: "レコードは 2000 件まで",
unreadable: "このデータベースを読み取れません",
saveError: "データベースを保存できませんでした",
editSessionError: "安全な編集セッションを開始できません。しばらくしてから再度お試しください。",
saving: "保存中",
saved: "保存しました",
unsaved: "未保存の変更",
back: "ノート一覧に戻る",
editField: "フィールド {{name}} を編集",
types: {
text: "テキスト",
number: "数値",
checkbox: "チェック",
date: "日付",
select: "選択",
url: "リンク",
},
operators: {
contains: "含む",
eq: "等しい",
empty: "空",
notEmpty: "空ではない",
},
defaultFields: {
name: "名前",
status: "状態",
date: "日付",
sample: "サンプル",
notStarted: "未着手",
inProgress: "進行中",
done: "完了",
},
},
diagram: {
normalNote: "通常ノート",
mindMap: "マインドマップ",
+60
View File
@@ -275,6 +275,66 @@ export const zhCN = {
createDisabled: "当前视图不可新建笔记",
creating: "正在创建",
},
structuredTable: {
name: "多维表格",
title: "表格标题",
addField: "添加字段",
addRecord: "添加记录",
deleteField: "删除字段",
deleteRecord: "删除这条记录",
fieldName: "字段名",
fieldType: "字段类型",
selectOptions: "选项",
selectOptionsHint: "用逗号分隔",
filter: "筛选",
addFilter: "添加筛选",
clearFilters: "清除筛选",
filterValue: "筛选值",
sort: "排序",
noSort: "不排序",
ascending: "升序",
descending: "降序",
empty: "还没有记录",
noMatches: "没有符合筛选的记录",
exportCsv: "导出 CSV",
recordCount: "{{count}} 条记录",
recordCountFiltered: "{{count}} / {{total}} 条记录",
listCounts: "{{records}} 条记录 · {{fields}} 个字段",
newField: "字段 {{index}}",
fieldLimit: "最多 40 个字段",
recordLimit: "最多 2000 条记录",
unreadable: "这份多维表格无法读取",
saveError: "表格保存失败",
editSessionError: "暂时无法建立安全编辑会话,请稍后重试。",
saving: "保存中",
saved: "已保存",
unsaved: "有未保存的修改",
back: "返回笔记列表",
editField: "编辑字段 {{name}}",
types: {
text: "文本",
number: "数字",
checkbox: "勾选",
date: "日期",
select: "单选",
url: "链接",
},
operators: {
contains: "包含",
eq: "等于",
empty: "为空",
notEmpty: "不为空",
},
defaultFields: {
name: "名称",
status: "状态",
date: "日期",
sample: "示例记录",
notStarted: "未开始",
inProgress: "进行中",
done: "完成",
},
},
diagram: {
normalNote: "普通笔记",
mindMap: "思维导图",
+1
View File
@@ -34,6 +34,7 @@ export * from "./desktop-rpc";
export * from "./details";
export * from "./deployment-metadata";
export * from "./diagram";
export * from "./table";
export * from "./diagram-palette";
export * from "./diagram-flowchart-style";
export * from "./diagram-architecture-style";
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test";
import { markdownToDoc } from "./content.ts";
import {
addTableField,
addTableRecord,
applyTableView,
createDefaultTableDocument,
hasTableDocumentMarker,
parseTableDocument,
removeTableField,
replaceTableView,
serializeTableDocument,
stripTableDocumentMarker,
tableDocumentToCsv,
tableFallbackMarkdown,
updateTableCell,
updateTableField,
getTableSummary,
} from "./table.ts";
describe("structured table documents", () => {
test("round-trips fields, records, filters, and sort through the markdown envelope", () => {
const seeded = createDefaultTableDocument();
const withView = replaceTableView(updateTableCell(seeded, "rec_sample", "fld_name", "阅读清单"), {
filters: [{ fieldId: "fld_status", operator: "eq", value: "未开始" }],
sort: { fieldId: "fld_name", direction: "asc" },
});
const markdown = serializeTableDocument(withView);
expect(hasTableDocumentMarker(markdown)).toBe(true);
expect(stripTableDocumentMarker(markdown)).toBe(tableFallbackMarkdown(withView));
expect(parseTableDocument(markdown)).toEqual(withView);
expect(markdownToDoc(tableFallbackMarkdown(withView)).content?.some((node) => node.type === "table")).toBe(true);
expect(JSON.stringify(markdownToDoc(stripTableDocumentMarker(markdown)))).not.toContain("edgeever-table-v1");
});
test("rejects a broken envelope without dropping the readable table", () => {
const markdown = `${tableFallbackMarkdown(createDefaultTableDocument())}\n\n<!-- edgeever-table-v1:not-json -->`;
expect(parseTableDocument(markdown)).toBeNull();
expect(hasTableDocumentMarker(markdown)).toBe(true);
expect(stripTableDocumentMarker(markdown)).toContain("| 名称 |");
expect(getTableSummary(markdown)).toEqual({ structuredTable: false });
});
test("filters, sorts, and keeps an ordinary markdown table untouched", () => {
let document = createDefaultTableDocument();
document = addTableRecord(document, "rec_next");
document = updateTableCell(document, "rec_next", "fld_name", "已完成的书");
document = updateTableCell(document, "rec_next", "fld_status", "完成");
document = updateTableCell(document, "rec_next", "fld_date", "2026-09-01");
document = replaceTableView(document, {
filters: [{ fieldId: "fld_name", operator: "contains", value: "书" }],
sort: { fieldId: "fld_date", direction: "desc" },
});
expect(applyTableView(document).map((record) => record.id)).toEqual(["rec_next"]);
expect(parseTableDocument("| 名称 |\n| --- |\n| 普通表格 |")).toBeNull();
});
test("coerces cells when a field type changes and refuses to remove the last field", () => {
const document = updateTableField(createDefaultTableDocument(), "fld_name", { type: "number" });
expect(document.records[0]?.cells.fld_name).toBeNull();
const onlyName = removeTableField(removeTableField(document, "fld_status"), "fld_date");
expect(removeTableField(onlyName, "fld_name").fields.map((field) => field.id)).toEqual(["fld_name"]);
});
test("exports csv for the rows currently in view and escapes commas", () => {
const document = updateTableCell(createDefaultTableDocument(), "rec_sample", "fld_name", "甲,乙");
expect(tableDocumentToCsv(document)).toContain('"甲,乙"');
expect(tableDocumentToCsv(document).charCodeAt(0)).toBe(0xfeff);
});
test("summarizes a table note for the memo list", () => {
expect(getTableSummary(serializeTableDocument(createDefaultTableDocument()))).toEqual({
structuredTable: true,
tablePreview: { fieldCount: 3, recordCount: 1, fieldNames: ["名称", "状态", "日期"] },
});
});
test("adds a field onto every existing record", () => {
const document = addTableField(createDefaultTableDocument(), { id: "fld_url", name: "链接", type: "url" });
expect(document.records[0]?.cells.fld_url).toBe("");
expect(document.fields.at(-1)?.name).toBe("链接");
});
});
+368
View File
@@ -0,0 +1,368 @@
import { Base64 } from "js-base64";
export const TABLE_SCHEMA_VERSION = 1 as const;
export const TABLE_FIELD_LIMIT = 40;
export const TABLE_RECORD_LIMIT = 2000;
export const TABLE_FIELD_TYPES = ["text", "number", "checkbox", "date", "select", "url"] as const;
export const TABLE_FILTER_OPERATORS = ["contains", "eq", "empty", "notEmpty"] as const;
export type TableFieldType = (typeof TABLE_FIELD_TYPES)[number];
export type TableFilterOperator = (typeof TABLE_FILTER_OPERATORS)[number];
export type TableCellValue = string | number | boolean | null;
export type TableField = {
id: string;
name: string;
type: TableFieldType;
options?: string[];
};
export type TableRecord = {
id: string;
cells: Record<string, TableCellValue>;
};
export type TableFilter = {
fieldId: string;
operator: TableFilterOperator;
value?: string;
};
export type TableSort = {
fieldId: string;
direction: "asc" | "desc";
};
export type TableView = {
filters: TableFilter[];
sort: TableSort | null;
};
export type TableDocument = {
schemaVersion: typeof TABLE_SCHEMA_VERSION;
fields: TableField[];
records: TableRecord[];
view: TableView;
};
export type TableSeedLabels = {
name?: string;
status?: string;
date?: string;
sample?: string;
notStarted?: string;
inProgress?: string;
done?: string;
};
export type TableSummaryPreview = {
fieldCount: number;
recordCount: number;
fieldNames: string[];
};
const TABLE_MARKER = "edgeever-table-v1";
const TABLE_COMMENT = new RegExp(`<!--\\s*${TABLE_MARKER}:([\\s\\S]*?)\\s*-->`);
const FIELD_NAME_LIMIT = 80;
const OPTION_LIMIT = 40;
const CELL_TEXT_LIMIT = 2000;
const encodeBase64Url = (value: string) => Base64.encodeURI(value);
const decodeBase64Url = (value: string) => Base64.decode(value);
export const createTableId = (prefix: "fld" | "rec") => {
const uuid = globalThis.crypto?.randomUUID?.();
const suffix = uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
return `${prefix}_${suffix}`;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === "object" && !Array.isArray(value);
const cleanName = (value: unknown, fallback: string) => {
const name = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
return (name || fallback).slice(0, FIELD_NAME_LIMIT);
};
const cleanOptions = (value: unknown) => {
if (!Array.isArray(value)) return [];
const options: string[] = [];
for (const item of value) {
if (typeof item !== "string") continue;
const option = item.replace(/\s+/g, " ").trim().slice(0, FIELD_NAME_LIMIT);
if (!option || options.includes(option)) continue;
options.push(option);
if (options.length >= OPTION_LIMIT) break;
}
return options;
};
export const coerceTableCell = (field: TableField, value: unknown): TableCellValue => {
if (field.type === "checkbox") {
return value === true || value === "true" || value === 1 || value === "1";
}
if (field.type === "number") {
if (value === null || value === undefined || value === "") return null;
const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) ? number : null;
}
if (value === null || value === undefined) return "";
return String(value).slice(0, CELL_TEXT_LIMIT);
};
const emptyCell = (field: TableField): TableCellValue => coerceTableCell(field, field.type === "checkbox" ? false : null);
const parseField = (value: unknown): TableField | null => {
if (!isRecord(value) || typeof value.id !== "string" || !value.id || value.id.length > 80) return null;
if (!TABLE_FIELD_TYPES.includes(value.type as TableFieldType)) return null;
const type = value.type as TableFieldType;
const field: TableField = { id: value.id, name: cleanName(value.name, "字段"), type };
if (type === "select") field.options = cleanOptions(value.options);
return field;
};
const parseView = (value: unknown, fieldIds: Set<string>): TableView => {
if (!isRecord(value)) return { filters: [], sort: null };
const filters: TableFilter[] = [];
if (Array.isArray(value.filters)) {
for (const item of value.filters) {
if (!isRecord(item) || typeof item.fieldId !== "string" || !fieldIds.has(item.fieldId)) continue;
if (!TABLE_FILTER_OPERATORS.includes(item.operator as TableFilterOperator)) continue;
const filter: TableFilter = { fieldId: item.fieldId, operator: item.operator as TableFilterOperator };
if (typeof item.value === "string") filter.value = item.value.slice(0, CELL_TEXT_LIMIT);
filters.push(filter);
if (filters.length >= 8) break;
}
}
const sort = isRecord(value.sort)
&& typeof value.sort.fieldId === "string"
&& fieldIds.has(value.sort.fieldId)
&& (value.sort.direction === "asc" || value.sort.direction === "desc")
? { fieldId: value.sort.fieldId, direction: value.sort.direction as "asc" | "desc" }
: null;
return { filters, sort };
};
export const parseTableDocument = (markdown: string | null | undefined): TableDocument | null => {
const encoded = markdown?.match(TABLE_COMMENT)?.[1]?.replace(/\s+/g, "");
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return null;
try {
const value = JSON.parse(decodeBase64Url(encoded)) as Record<string, unknown>;
if (value.schemaVersion !== TABLE_SCHEMA_VERSION || !Array.isArray(value.fields) || value.fields.length < 1) return null;
const fields: TableField[] = [];
for (const item of value.fields) {
const field = parseField(item);
if (!field || fields.some((current) => current.id === field.id)) return null;
fields.push(field);
}
if (!Array.isArray(value.records)) return null;
const fieldIds = new Set(fields.map((field) => field.id));
const records: TableRecord[] = [];
const recordIds = new Set<string>();
for (const item of value.records) {
if (!isRecord(item) || typeof item.id !== "string" || !item.id || item.id.length > 80 || recordIds.has(item.id)) return null;
recordIds.add(item.id);
const rawCells = isRecord(item.cells) ? item.cells : {};
const cells: Record<string, TableCellValue> = {};
for (const field of fields) cells[field.id] = coerceTableCell(field, rawCells[field.id]);
records.push({ id: item.id, cells });
}
return { schemaVersion: TABLE_SCHEMA_VERSION, fields, records, view: parseView(value.view, fieldIds) };
} catch {
return null;
}
};
export const hasTableDocumentMarker = (markdown: string | null | undefined) =>
Boolean(markdown?.match(TABLE_COMMENT));
export const stripTableDocumentMarker = (markdown: string | null | undefined) =>
(markdown ?? "").replace(TABLE_COMMENT, "").trimEnd();
const markdownCell = (field: TableField, value: TableCellValue) => {
if (value === null || value === undefined || value === "") return "";
const text = field.type === "checkbox" ? (value === true ? "true" : "false") : String(value);
return text.replace(/\r?\n/g, " ").replace(/\|/g, "\\|");
};
export const tableFallbackMarkdown = (document: TableDocument) => {
const header = document.fields.map((field) => markdownCell({ ...field, type: "text" }, field.name) || "字段");
const divider = header.map(() => "---");
const rows = document.records.map((record) => document.fields.map((field) => markdownCell(field, record.cells[field.id] ?? null)));
return [header, divider, ...rows].map((row) => `| ${row.join(" | ")} |`).join("\n");
};
export const serializeTableDocument = (document: TableDocument) =>
`${tableFallbackMarkdown(document)}\n\n<!-- ${TABLE_MARKER}:${encodeBase64Url(JSON.stringify(document))} -->`;
export const createDefaultTableDocument = (labels: TableSeedLabels = {}): TableDocument => {
const nameId = "fld_name";
const statusId = "fld_status";
const dateId = "fld_date";
const notStarted = labels.notStarted ?? "未开始";
const inProgress = labels.inProgress ?? "进行中";
const done = labels.done ?? "完成";
return {
schemaVersion: TABLE_SCHEMA_VERSION,
fields: [
{ id: nameId, name: labels.name ?? "名称", type: "text" },
{ id: statusId, name: labels.status ?? "状态", type: "select", options: [notStarted, inProgress, done] },
{ id: dateId, name: labels.date ?? "日期", type: "date" },
],
records: [{
id: "rec_sample",
cells: {
[nameId]: labels.sample ?? "示例记录",
[statusId]: notStarted,
[dateId]: "",
},
}],
view: { filters: [], sort: null },
};
};
const csvCell = (value: TableCellValue) => {
const text = value === null || value === undefined ? "" : value === true ? "true" : value === false ? "false" : String(value);
return /[",\n]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
};
export const tableDocumentToCsv = (document: TableDocument, records: TableRecord[] = document.records) => {
const lines = [
document.fields.map((field) => csvCell(field.name)),
...records.map((record) => document.fields.map((field) => csvCell(record.cells[field.id] ?? null))),
];
return `\uFEFF${lines.map((line) => line.join(",")).join("\n")}`;
};
const cellSortText = (field: TableField, value: TableCellValue) => {
if (value === null || value === undefined || value === "") return null;
if (field.type === "number") return typeof value === "number" ? value : null;
if (field.type === "checkbox") return value === true ? 1 : 0;
return String(value);
};
const matchesFilter = (field: TableField, value: TableCellValue, filter: TableFilter) => {
const empty = field.type === "checkbox" ? value !== true : value === null || value === "";
if (filter.operator === "empty") return empty;
if (filter.operator === "notEmpty") return !empty;
if (filter.operator === "eq") {
if (field.type === "number") {
const expected = Number(filter.value);
return Number.isFinite(expected) && value === expected;
}
if (field.type === "checkbox") return (filter.value === "true") === (value === true);
return String(value ?? "") === (filter.value ?? "");
}
return String(value ?? "").toLocaleLowerCase().includes((filter.value ?? "").trim().toLocaleLowerCase());
};
export const applyTableView = (document: TableDocument, records = document.records) => {
const fields = new Map(document.fields.map((field) => [field.id, field]));
const filtered = records.filter((record) => document.view.filters.every((filter) => {
const field = fields.get(filter.fieldId);
return field ? matchesFilter(field, record.cells[field.id] ?? null, filter) : true;
}));
const sort = document.view.sort ? fields.get(document.view.sort.fieldId) : undefined;
if (!sort || !document.view.sort) return filtered;
const direction = document.view.sort.direction === "desc" ? -1 : 1;
return [...filtered].sort((left, right) => {
const a = cellSortText(sort, left.cells[sort.id] ?? null);
const b = cellSortText(sort, right.cells[sort.id] ?? null);
if (a === null && b === null) return 0;
if (a === null) return 1;
if (b === null) return -1;
if (typeof a === "number" && typeof b === "number") return (a - b) * direction;
return String(a).localeCompare(String(b)) * direction;
});
};
export const addTableField = (document: TableDocument, field: TableField): TableDocument => {
if (document.fields.length >= TABLE_FIELD_LIMIT || document.fields.some((item) => item.id === field.id)) return document;
const nextField = field.type === "select" ? { ...field, options: field.options ?? [] } : { ...field, options: undefined };
return {
...document,
fields: [...document.fields, nextField],
records: document.records.map((record) => ({ ...record, cells: { ...record.cells, [nextField.id]: emptyCell(nextField) } })),
};
};
export const updateTableField = (document: TableDocument, fieldId: string, patch: Partial<Pick<TableField, "name" | "type" | "options">>): TableDocument => {
const current = document.fields.find((field) => field.id === fieldId);
if (!current) return document;
const next: TableField = {
...current,
name: patch.name === undefined ? current.name : cleanName(patch.name, current.name),
type: patch.type ?? current.type,
};
if (next.type === "select") next.options = cleanOptions(patch.options ?? current.options ?? []);
return {
...document,
fields: document.fields.map((field) => field.id === fieldId ? next : field),
records: document.records.map((record) => ({
...record,
cells: { ...record.cells, [fieldId]: coerceTableCell(next, record.cells[fieldId]) },
})),
};
};
export const removeTableField = (document: TableDocument, fieldId: string): TableDocument => {
if (document.fields.length <= 1 || !document.fields.some((field) => field.id === fieldId)) return document;
const fields = document.fields.filter((field) => field.id !== fieldId);
const fieldIds = new Set(fields.map((field) => field.id));
return {
...document,
fields,
records: document.records.map((record) => ({
...record,
cells: Object.fromEntries(Object.entries(record.cells).filter(([id]) => fieldIds.has(id))),
})),
view: {
filters: document.view.filters.filter((filter) => filter.fieldId !== fieldId),
sort: document.view.sort?.fieldId === fieldId ? null : document.view.sort,
},
};
};
export const addTableRecord = (document: TableDocument, id = createTableId("rec")): TableDocument => {
if (document.records.length >= TABLE_RECORD_LIMIT || document.records.some((record) => record.id === id)) return document;
const cells: Record<string, TableCellValue> = {};
for (const field of document.fields) cells[field.id] = emptyCell(field);
return { ...document, records: [...document.records, { id, cells }] };
};
export const updateTableCell = (document: TableDocument, recordId: string, fieldId: string, value: unknown): TableDocument => {
const field = document.fields.find((item) => item.id === fieldId);
if (!field) return document;
return {
...document,
records: document.records.map((record) => record.id === recordId
? { ...record, cells: { ...record.cells, [fieldId]: coerceTableCell(field, value) } }
: record),
};
};
export const removeTableRecord = (document: TableDocument, recordId: string): TableDocument => ({
...document,
records: document.records.filter((record) => record.id !== recordId),
});
export const replaceTableView = (document: TableDocument, view: TableView): TableDocument => ({
...document,
view: parseView(view, new Set(document.fields.map((field) => field.id))),
});
export const getTableSummary = (markdown: string | null | undefined): {
structuredTable: boolean;
tablePreview?: TableSummaryPreview;
} => {
const document = parseTableDocument(markdown);
if (!document) return { structuredTable: false };
return {
structuredTable: true,
tablePreview: {
fieldCount: document.fields.length,
recordCount: document.records.length,
fieldNames: document.fields.map((field) => field.name).filter(Boolean).slice(0, 4),
},
};
};
+5
View File
@@ -1,6 +1,9 @@
import type { TiptapDoc } from "./content";
import type { AiAction, AiPromptParameterKind, AiPromptResultMode } from "./ai-assistant";
import type { DiagramKind, DiagramSummaryPreview } from "./diagram";
import type { TableSummaryPreview } from "./table";
export type NoteCreateKind = DiagramKind | "table";
export type Notebook = {
id: string;
@@ -23,6 +26,8 @@ export type MemoSummary = {
excerpt: string;
diagramKind?: DiagramKind | null;
diagramPreview?: DiagramSummaryPreview;
structuredTable?: boolean;
tablePreview?: TableSummaryPreview;
tags: string[];
isPinned: boolean;
isArchived: boolean;