fix(web): preserve rich table cells during Markdown edits
This commit is contained in:
1 parent
fbcb1367bf
commit
8cecef3360
10 files changed
+261
-41
No files matched your search
@@ -63,6 +63,7 @@ import {
|
||||
remapEditorInstanceMemoIdentity,
|
||||
} from "./editor/editor-instance-identity";
|
||||
import {
|
||||
analyzeMarkdownModeContent,
|
||||
isMarkdownSourceUnchanged,
|
||||
resolveMarkdownModeContent,
|
||||
} from "./editor/editor-mode-content";
|
||||
@@ -454,6 +455,8 @@ const RichEditorPane = ({
|
||||
setSaveState,
|
||||
} = useEditorSaveStatus();
|
||||
const [storageSaveError, setStorageSaveError] = useState(false);
|
||||
const [richTableEditBlocked, setRichTableEditBlocked] = useState(false);
|
||||
const revertingUnsafeMarkdownRef = useRef(false);
|
||||
const [hydratedEditorMemoId, setHydratedEditorMemoId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1347,6 +1350,7 @@ const RichEditorPane = ({
|
||||
clearMarkdownSnapshot,
|
||||
handleMarkdownModeChange,
|
||||
handleMarkdownSourceReady,
|
||||
getMarkdownSource,
|
||||
hydrateMarkdownSource,
|
||||
isMarkdownMode,
|
||||
markdownModeSnapshotRef,
|
||||
@@ -1361,6 +1365,7 @@ const RichEditorPane = ({
|
||||
effectiveReadOnly: readOnly || (isMobileViewport && !mobileEditingActive),
|
||||
getMemoId: () => memoRef.current?.id,
|
||||
hydratingRef,
|
||||
onUnsafeRichTableEdit: setRichTableEditBlocked,
|
||||
});
|
||||
const useMarkdownSourceEditor = !useMobilePlainTextEditor && isMarkdownMode;
|
||||
|
||||
@@ -2673,9 +2678,18 @@ const RichEditorPane = ({
|
||||
}, []);
|
||||
|
||||
const handleMarkdownSourceChange = useCallback((value: string) => {
|
||||
setMarkdownSource(value);
|
||||
if (revertingUnsafeMarkdownRef.current) return;
|
||||
if (!setMarkdownSource(value)) {
|
||||
revertingUnsafeMarkdownRef.current = true;
|
||||
try {
|
||||
markdownSourceEditorRef.current?.replaceDocument(getMarkdownSource());
|
||||
} finally {
|
||||
revertingUnsafeMarkdownRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
markDirty();
|
||||
}, [markDirty]);
|
||||
}, [getMarkdownSource, markDirty, setMarkdownSource]);
|
||||
|
||||
const {
|
||||
handleCopyToWeChat,
|
||||
@@ -2745,6 +2759,14 @@ const RichEditorPane = ({
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const currentMemo = memoRef.current;
|
||||
if (useMarkdownSourceEditor && analyzeMarkdownModeContent(
|
||||
markdownModeSnapshotRef.current,
|
||||
currentMemo?.id,
|
||||
markdownSource,
|
||||
).hasUnsafeRichTableEdit) {
|
||||
setRichTableEditBlocked(true);
|
||||
throw new Error("Rich table cell cannot be safely converted from Markdown");
|
||||
}
|
||||
const contentJson = getCurrentContentJson();
|
||||
const editSession = editSessionRef.current;
|
||||
const writableFields = getWritableEditorMemoFields(
|
||||
@@ -4203,6 +4225,11 @@ const RichEditorPane = ({
|
||||
</>
|
||||
) : useMarkdownSourceEditor ? (
|
||||
<div ref={markdownMenuAnchorRef} className="relative min-h-0 flex-1">
|
||||
{richTableEditBlocked ? (
|
||||
<div role="alert" className="absolute inset-x-3 bottom-3 z-50 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-950 shadow-sm dark:border-amber-700 dark:bg-amber-950 dark:text-amber-100">
|
||||
{t("editor.richTableSourceProtection")}
|
||||
</div>
|
||||
) : null}
|
||||
<Suspense fallback={<div className="h-full w-full" />}>
|
||||
<MarkdownSourceEditor
|
||||
ref={markdownSourceEditorRef}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface MarkdownSourceEditorRef {
|
||||
getScrollContainer: () => HTMLElement | null;
|
||||
getSelection: () => { from: number; to: number };
|
||||
setSelection: (from: number, to: number) => void;
|
||||
replaceDocument: (value: string) => void;
|
||||
focus: () => void;
|
||||
insertText: (text: string, from?: number, to?: number) => void;
|
||||
sliceText: (from: number, to: number) => string;
|
||||
@@ -179,6 +180,15 @@ export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, Markdown
|
||||
scrollIntoView: true,
|
||||
});
|
||||
},
|
||||
replaceDocument: (value: string) => {
|
||||
const view = cmRef.current?.view;
|
||||
if (!view || view.state.doc.toString() === value) return;
|
||||
const anchor = Math.min(view.state.selection.main.anchor, value.length);
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
selection: { anchor },
|
||||
});
|
||||
},
|
||||
focus: () => {
|
||||
cmRef.current?.view?.focus();
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { docToMarkdown, markdownToDoc } from "@edgeever/shared";
|
||||
import {
|
||||
analyzeMarkdownModeContent,
|
||||
createMarkdownModeSnapshot,
|
||||
docToEditableMarkdown,
|
||||
isMarkdownSourceUnchanged,
|
||||
@@ -97,13 +98,31 @@ describe("Markdown editor mode content preservation", () => {
|
||||
expect(docToMarkdown(resolved)).toContain("新增段落");
|
||||
});
|
||||
|
||||
test("parses a rich table cell only when that cell was actually edited", () => {
|
||||
test("preserves a rich table task item when its source text changes", () => {
|
||||
const snapshot = createMarkdownModeSnapshot("memo-1", tableTaskListDocument);
|
||||
const changedSource = snapshot.markdownSource.replace("待办", "待办 updated");
|
||||
const resolved = resolveMarkdownModeContent(snapshot, "memo-1", changedSource);
|
||||
const result = analyzeMarkdownModeContent(snapshot, "memo-1", changedSource);
|
||||
|
||||
expect(docToMarkdown(resolved)).toContain("updated");
|
||||
expect(resolved.content[0].content[1].content[0].content[0].type).toBe("paragraph");
|
||||
expect(result.hasUnsafeRichTableEdit).toBe(false);
|
||||
expect(docToMarkdown(result.contentJson)).toContain("updated");
|
||||
expect(result.contentJson.content[0].content[1].content[0].content[0].type).toBe("taskList");
|
||||
expect(result.contentJson.content[0].content[1].content[0].content[0].content[0].attrs.checked).toBe(false);
|
||||
});
|
||||
|
||||
test("updates a rich table task item when its Markdown checkbox is toggled", () => {
|
||||
const snapshot = createMarkdownModeSnapshot("memo-1", tableTaskListDocument);
|
||||
const changedSource = snapshot.markdownSource.replace("- [ ] 待办", "- [x] 待办");
|
||||
const result = analyzeMarkdownModeContent(snapshot, "memo-1", changedSource);
|
||||
|
||||
expect(result.hasUnsafeRichTableEdit).toBe(false);
|
||||
expect(result.contentJson.content[0].content[1].content[0].content[0].content[0].attrs.checked).toBe(true);
|
||||
});
|
||||
|
||||
test("flags table shape changes rather than silently discarding a rich cell", () => {
|
||||
const snapshot = createMarkdownModeSnapshot("memo-1", tableTaskListDocument);
|
||||
const changedSource = snapshot.markdownSource.replace("| - [ ] 待办 |", "");
|
||||
|
||||
expect(analyzeMarkdownModeContent(snapshot, "memo-1", changedSource).hasUnsafeRichTableEdit).toBe(true);
|
||||
});
|
||||
|
||||
test("never reuses a snapshot belonging to another memo", () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type MarkdownModeSnapshot = {
|
||||
memoId: string;
|
||||
contentJson: TiptapDoc;
|
||||
markdownSource: string;
|
||||
hasRichOnlyTableCells: boolean;
|
||||
};
|
||||
|
||||
const normalizeMarkdownSource = (value: string) => value.replace(/\r\n?/g, "\n");
|
||||
@@ -38,12 +39,6 @@ type ContentNode = {
|
||||
const isTableCell = (node: ContentNode) =>
|
||||
node.type === "tableCell" || node.type === "tableHeader";
|
||||
|
||||
const hasBlockContent = (node: ContentNode) => isTableCell(node) && (
|
||||
!Array.isArray(node.content)
|
||||
|| node.content.length !== 1
|
||||
|| node.content[0]?.type !== "paragraph"
|
||||
);
|
||||
|
||||
const collectTables = (value: unknown): ContentNode[] => {
|
||||
const tables: ContentNode[] = [];
|
||||
const visit = (node: unknown) => {
|
||||
@@ -75,22 +70,129 @@ const collectCells = (table: ContentNode): ContentNode[] => {
|
||||
const nodesEqual = (left: ContentNode, right: ContentNode) =>
|
||||
JSON.stringify(left) === JSON.stringify(right);
|
||||
|
||||
const hasRichOnlyTableCells = (original: TiptapDoc, projection: TiptapDoc): boolean => {
|
||||
const originalTables = collectTables(original);
|
||||
const projectedTables = collectTables(projection);
|
||||
return originalTables.some((table, tableIndex) => {
|
||||
const originalCells = collectCells(table);
|
||||
const projectedCells = collectCells(projectedTables[tableIndex] ?? {});
|
||||
return originalCells.length !== projectedCells.length
|
||||
|| originalCells.some((cell, cellIndex) => !nodesEqual(cell, projectedCells[cellIndex] ?? {}));
|
||||
});
|
||||
};
|
||||
|
||||
const RICH_CELL_MARKER = "\uE000EDGEEVERRICHCELL\uE001";
|
||||
|
||||
const cellText = (cell: ContentNode): string | null => {
|
||||
if (cell.content?.length !== 1 || cell.content[0]?.type !== "paragraph") return null;
|
||||
const inline = cell.content[0].content ?? [];
|
||||
return inline.every((node) => node.type === "text" && typeof node.text === "string")
|
||||
? inline.map((node) => node.text).join("")
|
||||
: null;
|
||||
};
|
||||
|
||||
const textNodePaths = (node: ContentNode, path: number[] = []): number[][] => {
|
||||
if (node.type === "text" && typeof node.text === "string") return [path];
|
||||
return node.content?.flatMap((child, index) => textNodePaths(child, [...path, index])) ?? [];
|
||||
};
|
||||
|
||||
const taskItemPaths = (node: ContentNode, path: number[] = []): number[][] => [
|
||||
...(node.type === "taskItem" && typeof (node.attrs as { checked?: unknown } | undefined)?.checked === "boolean"
|
||||
? [path]
|
||||
: []),
|
||||
...(node.content?.flatMap((child, index) => taskItemPaths(child, [...path, index])) ?? []),
|
||||
];
|
||||
|
||||
const nodeAtPath = (node: ContentNode, path: number[]): ContentNode | null =>
|
||||
path.reduce<ContentNode | null>((current, index) => current?.content?.[index] ?? null, node);
|
||||
|
||||
const projectedCell = (doc: TiptapDoc, tableIndex: number, cellIndex: number): ContentNode | null => {
|
||||
const parsed = markdownToDoc(docToMarkdown(doc));
|
||||
const table = collectTables(parsed)[tableIndex];
|
||||
return table ? collectCells(table)[cellIndex] ?? null : null;
|
||||
};
|
||||
|
||||
/** Change one rich text leaf only when its Markdown projection exactly matches the edited cell. */
|
||||
const restoreEditedRichCell = (
|
||||
original: TiptapDoc,
|
||||
originalCell: ContentNode,
|
||||
originalProjectionCell: ContentNode,
|
||||
editedCell: ContentNode,
|
||||
tableIndex: number,
|
||||
cellIndex: number,
|
||||
): ContentNode | null => {
|
||||
const before = cellText(originalProjectionCell);
|
||||
const after = cellText(editedCell);
|
||||
if (before === null || after === null) return null;
|
||||
|
||||
for (const path of textNodePaths(originalCell)) {
|
||||
const marked = cloneContentJson(original);
|
||||
const markedCell = collectCells(collectTables(marked)[tableIndex]!)[cellIndex]!;
|
||||
const markedTextNode = nodeAtPath(markedCell, path);
|
||||
const oldText = markedTextNode?.text;
|
||||
if (typeof oldText !== "string") continue;
|
||||
markedTextNode!.text = RICH_CELL_MARKER;
|
||||
const markedText = cellText(projectedCell(marked, tableIndex, cellIndex) ?? {});
|
||||
const markerAt = markedText?.indexOf(RICH_CELL_MARKER) ?? -1;
|
||||
if (
|
||||
markerAt < 0
|
||||
|| markedText!.indexOf(RICH_CELL_MARKER, markerAt + RICH_CELL_MARKER.length) !== -1
|
||||
|| markedText!.replace(RICH_CELL_MARKER, oldText) !== before
|
||||
) continue;
|
||||
|
||||
const prefix = markedText!.slice(0, markerAt);
|
||||
const suffix = markedText!.slice(markerAt + RICH_CELL_MARKER.length);
|
||||
if (!after.startsWith(prefix) || !after.endsWith(suffix)) continue;
|
||||
const replacement = after.slice(prefix.length, after.length - suffix.length);
|
||||
if (!replacement) continue;
|
||||
|
||||
const candidate = cloneContentJson(original);
|
||||
const candidateCell = collectCells(collectTables(candidate)[tableIndex]!)[cellIndex]!;
|
||||
nodeAtPath(candidateCell, path)!.text = replacement;
|
||||
const projection = projectedCell(candidate, tableIndex, cellIndex);
|
||||
if (projection && nodesEqual(projection, editedCell)) return candidateCell;
|
||||
}
|
||||
|
||||
for (const path of taskItemPaths(originalCell)) {
|
||||
const candidate = cloneContentJson(original);
|
||||
const candidateCell = collectCells(collectTables(candidate)[tableIndex]!)[cellIndex]!;
|
||||
const taskItem = nodeAtPath(candidateCell, path);
|
||||
const attrs = taskItem?.attrs as { checked: boolean } | undefined;
|
||||
if (!attrs) continue;
|
||||
attrs.checked = !attrs.checked;
|
||||
const projection = projectedCell(candidate, tableIndex, cellIndex);
|
||||
if (projection && nodesEqual(projection, editedCell)) return candidateCell;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export type MarkdownModeContentResult = {
|
||||
contentJson: TiptapDoc;
|
||||
hasUnsafeRichTableEdit: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Markdown flattens block nodes inside GFM table cells. Rehydrate an original
|
||||
* rich cell only when its Markdown projection is still byte-for-byte
|
||||
* equivalent to the parsed edited cell. Edits elsewhere in the source can
|
||||
* therefore round-trip without damaging the untouched cell.
|
||||
* GFM flattens rich table cells. Restore untouched cells and edits that map to
|
||||
* exactly one rich text leaf or task checkbox; report every other change so
|
||||
* the source editor can reject it before autosave or mode switching.
|
||||
*/
|
||||
const restoreUnchangedRichTableCells = (
|
||||
const restoreRichTableCells = (
|
||||
original: TiptapDoc,
|
||||
originalProjection: TiptapDoc,
|
||||
editedProjection: TiptapDoc,
|
||||
): TiptapDoc => {
|
||||
): MarkdownModeContentResult => {
|
||||
const restored = cloneContentJson(editedProjection);
|
||||
const originalTables = collectTables(original);
|
||||
const projectedTables = collectTables(originalProjection);
|
||||
const editedTables = collectTables(restored);
|
||||
|
||||
let hasUnsafeRichTableEdit = false;
|
||||
|
||||
if (originalTables.length !== projectedTables.length || originalTables.length !== editedTables.length) {
|
||||
return { contentJson: restored, hasUnsafeRichTableEdit: hasRichOnlyTableCells(original, originalProjection) };
|
||||
}
|
||||
|
||||
originalTables.forEach((originalTable, tableIndex) => {
|
||||
const projectedTable = projectedTables[tableIndex];
|
||||
const editedTable = editedTables[tableIndex];
|
||||
@@ -102,25 +204,41 @@ const restoreUnchangedRichTableCells = (
|
||||
if (
|
||||
originalCells.length !== projectedCells.length
|
||||
|| projectedCells.length !== editedCells.length
|
||||
) return;
|
||||
) {
|
||||
if (originalCells.some((cell, index) => !nodesEqual(cell, projectedCells[index] ?? {}))) {
|
||||
hasUnsafeRichTableEdit = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
originalCells.forEach((originalCell, cellIndex) => {
|
||||
if (
|
||||
hasBlockContent(originalCell)
|
||||
&& nodesEqual(projectedCells[cellIndex]!, editedCells[cellIndex]!)
|
||||
) {
|
||||
if (!nodesEqual(originalCell, projectedCells[cellIndex]!)) {
|
||||
const replacement = nodesEqual(projectedCells[cellIndex]!, editedCells[cellIndex]!)
|
||||
? originalCell
|
||||
: restoreEditedRichCell(
|
||||
original,
|
||||
originalCell,
|
||||
projectedCells[cellIndex]!,
|
||||
editedCells[cellIndex]!,
|
||||
tableIndex,
|
||||
cellIndex,
|
||||
);
|
||||
if (!replacement) {
|
||||
hasUnsafeRichTableEdit = true;
|
||||
return;
|
||||
}
|
||||
Object.keys(editedCells[cellIndex]!).forEach((key) => {
|
||||
delete editedCells[cellIndex]![key];
|
||||
});
|
||||
Object.assign(
|
||||
editedCells[cellIndex]!,
|
||||
JSON.parse(JSON.stringify(originalCell)) as ContentNode,
|
||||
JSON.parse(JSON.stringify(replacement)) as ContentNode,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return restored;
|
||||
return { contentJson: restored, hasUnsafeRichTableEdit };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -136,6 +254,7 @@ export const createMarkdownModeSnapshot = (
|
||||
memoId,
|
||||
contentJson: cloneContentJson(contentJson),
|
||||
markdownSource,
|
||||
hasRichOnlyTableCells: hasRichOnlyTableCells(contentJson, markdownToDoc(markdownSource)),
|
||||
});
|
||||
|
||||
export const isMarkdownSourceUnchanged = (
|
||||
@@ -150,27 +269,37 @@ export const isMarkdownSourceUnchanged = (
|
||||
);
|
||||
|
||||
/** Resolve the document used by mode switching, drafts, autosave, and recovery. */
|
||||
export const resolveMarkdownModeContent = (
|
||||
export const analyzeMarkdownModeContent = (
|
||||
snapshot: MarkdownModeSnapshot | null,
|
||||
memoId: string | null | undefined,
|
||||
markdownSource: string,
|
||||
): TiptapDoc => {
|
||||
): MarkdownModeContentResult => {
|
||||
if (isMarkdownSourceUnchanged(snapshot, memoId, markdownSource)) {
|
||||
return snapshot!.contentJson;
|
||||
return { contentJson: snapshot!.contentJson, hasUnsafeRichTableEdit: false };
|
||||
}
|
||||
|
||||
const editedProjection = markdownToDoc(markdownSource);
|
||||
if (!snapshot || !memoId || snapshot.memoId !== memoId) {
|
||||
return editedProjection;
|
||||
return { contentJson: editedProjection, hasUnsafeRichTableEdit: false };
|
||||
}
|
||||
|
||||
return restoreUnchangedRichTableCells(
|
||||
if (!snapshot.hasRichOnlyTableCells) {
|
||||
return { contentJson: editedProjection, hasUnsafeRichTableEdit: false };
|
||||
}
|
||||
|
||||
return restoreRichTableCells(
|
||||
snapshot.contentJson,
|
||||
markdownToDoc(snapshot.markdownSource),
|
||||
editedProjection,
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveMarkdownModeContent = (
|
||||
snapshot: MarkdownModeSnapshot | null,
|
||||
memoId: string | null | undefined,
|
||||
markdownSource: string,
|
||||
): TiptapDoc => analyzeMarkdownModeContent(snapshot, memoId, markdownSource).contentJson;
|
||||
|
||||
/**
|
||||
* After autosave the editor hydrates from JSON. Serialization is lossy (extra
|
||||
* blank lines, ` ` markers). If the incoming document is only a projection
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { TiptapDoc } from "@edgeever/shared";
|
||||
import {
|
||||
analyzeMarkdownModeContent,
|
||||
createMarkdownModeSnapshot,
|
||||
resolveMarkdownModeContent,
|
||||
selectMarkdownSourceForDocument,
|
||||
shouldKeepLiveMarkdownSource,
|
||||
type MarkdownModeSnapshot,
|
||||
@@ -25,15 +25,17 @@ export const useEditorMarkdownMode = ({
|
||||
effectiveReadOnly,
|
||||
getMemoId,
|
||||
hydratingRef,
|
||||
onUnsafeRichTableEdit,
|
||||
}: {
|
||||
editor: Editor | null;
|
||||
editorScrollContainerRef: RefObject<HTMLDivElement | null>;
|
||||
effectiveReadOnly: boolean;
|
||||
getMemoId: () => string | null | undefined;
|
||||
hydratingRef: MutableRefObject<boolean>;
|
||||
onUnsafeRichTableEdit: (unsafe: boolean) => void;
|
||||
}) => {
|
||||
const [isMarkdownMode, setIsMarkdownMode] = useState(false);
|
||||
const [markdownSource, setMarkdownSource] = useState("");
|
||||
const [markdownSource, setMarkdownSourceState] = useState("");
|
||||
const markdownSourceRef = useRef(markdownSource);
|
||||
markdownSourceRef.current = markdownSource;
|
||||
const markdownSourceEditorRef = useRef<MarkdownSourceEditorRef | null>(null);
|
||||
@@ -42,6 +44,23 @@ export const useEditorMarkdownMode = ({
|
||||
const getMemoIdRef = useRef(getMemoId);
|
||||
getMemoIdRef.current = getMemoId;
|
||||
|
||||
const setMarkdownSource = useCallback((next: string | ((current: string) => string)): boolean => {
|
||||
const value = typeof next === "function" ? next(markdownSourceRef.current) : next;
|
||||
const snapshot = markdownModeSnapshotRef.current;
|
||||
const unsafe = Boolean(snapshot?.hasRichOnlyTableCells) && analyzeMarkdownModeContent(
|
||||
snapshot,
|
||||
getMemoIdRef.current(),
|
||||
value,
|
||||
).hasUnsafeRichTableEdit;
|
||||
onUnsafeRichTableEdit(unsafe);
|
||||
if (unsafe) return false;
|
||||
markdownSourceRef.current = value;
|
||||
setMarkdownSourceState(value);
|
||||
return true;
|
||||
}, [onUnsafeRichTableEdit]);
|
||||
|
||||
const getMarkdownSource = useCallback(() => markdownSourceRef.current, []);
|
||||
|
||||
const handleMarkdownSourceReady = useCallback(() => {
|
||||
const sourceEditor = markdownSourceEditorRef.current;
|
||||
if (!sourceEditor) return;
|
||||
@@ -75,11 +94,16 @@ export const useEditorMarkdownMode = ({
|
||||
}
|
||||
|
||||
const currentMemoId = getMemoId();
|
||||
const content = resolveMarkdownModeContent(
|
||||
const resolved = analyzeMarkdownModeContent(
|
||||
markdownModeSnapshotRef.current,
|
||||
currentMemoId,
|
||||
markdownSourceRef.current,
|
||||
);
|
||||
if (resolved.hasUnsafeRichTableEdit) {
|
||||
onUnsafeRichTableEdit(true);
|
||||
return;
|
||||
}
|
||||
const content = resolved.contentJson;
|
||||
const sourceSelection = markdownSourceEditorRef.current?.getSelection();
|
||||
hydratingRef.current = true;
|
||||
editor.commands.setContent(content);
|
||||
@@ -104,7 +128,7 @@ export const useEditorMarkdownMode = ({
|
||||
window.setTimeout(() => {
|
||||
hydratingRef.current = false;
|
||||
}, 0);
|
||||
}, [editor, getMemoId, hydratingRef, restoreScrollAfterModeChange]);
|
||||
}, [editor, getMemoId, hydratingRef, onUnsafeRichTableEdit, restoreScrollAfterModeChange]);
|
||||
|
||||
const handleMarkdownModeChange = useCallback(() => {
|
||||
if (effectiveReadOnly || !isEditorReady(editor)) {
|
||||
@@ -145,7 +169,9 @@ export const useEditorMarkdownMode = ({
|
||||
contentJson,
|
||||
markdown,
|
||||
);
|
||||
setMarkdownSource(markdown);
|
||||
setMarkdownSourceState(markdown);
|
||||
markdownSourceRef.current = markdown;
|
||||
onUnsafeRichTableEdit(false);
|
||||
setIsMarkdownMode(true);
|
||||
if (!pendingSourceSelectionRef.current) restoreScrollAfterModeChange("markdown", scrollProgress);
|
||||
}, [
|
||||
@@ -155,15 +181,18 @@ export const useEditorMarkdownMode = ({
|
||||
effectiveReadOnly,
|
||||
getMemoId,
|
||||
isMarkdownMode,
|
||||
onUnsafeRichTableEdit,
|
||||
restoreScrollAfterModeChange,
|
||||
]);
|
||||
|
||||
const resetMarkdownMode = useCallback(() => {
|
||||
markdownModeSnapshotRef.current = null;
|
||||
pendingSourceSelectionRef.current = null;
|
||||
setMarkdownSource("");
|
||||
setMarkdownSourceState("");
|
||||
markdownSourceRef.current = "";
|
||||
onUnsafeRichTableEdit(false);
|
||||
setIsMarkdownMode(false);
|
||||
}, []);
|
||||
}, [onUnsafeRichTableEdit]);
|
||||
|
||||
const hydrateMarkdownSource = useCallback((
|
||||
memoId: string,
|
||||
@@ -182,11 +211,13 @@ export const useEditorMarkdownMode = ({
|
||||
});
|
||||
const nextSource = keepLiveSource ? markdownSourceRef.current : markdown;
|
||||
if (!keepLiveSource) {
|
||||
setMarkdownSource(markdown);
|
||||
setMarkdownSourceState(markdown);
|
||||
markdownSourceRef.current = markdown;
|
||||
}
|
||||
onUnsafeRichTableEdit(false);
|
||||
markdownModeSnapshotRef.current = createMarkdownModeSnapshot(memoId, content, nextSource);
|
||||
return keepLiveSource;
|
||||
}, [isMarkdownMode]);
|
||||
}, [isMarkdownMode, onUnsafeRichTableEdit]);
|
||||
|
||||
const clearMarkdownSnapshot = useCallback(() => {
|
||||
markdownModeSnapshotRef.current = null;
|
||||
@@ -197,6 +228,7 @@ export const useEditorMarkdownMode = ({
|
||||
clearMarkdownSnapshot,
|
||||
handleMarkdownModeChange,
|
||||
handleMarkdownSourceReady,
|
||||
getMarkdownSource,
|
||||
hydrateMarkdownSource,
|
||||
isMarkdownMode,
|
||||
markdownModeSnapshotRef,
|
||||
|
||||
@@ -42,7 +42,7 @@ Automation must cover conversion and snapshot assertions, save and reopen, and a
|
||||
|
||||
- The save path stores both `contentJson` and `contentMarkdown`. Source-view saves submit the user's source; rich-view saves derive Markdown from JSON on the server. Thus C does not promise exact source after a rich text edit.
|
||||
- Existing unit tests cover readable source for plain `[`, `]`, and `&`, necessary escapes, unchanged-source snapshots, an untouched complex table cell, and some caret mapping. A local browser check covered continued typing after ordinary punctuation.
|
||||
- D is not fully met: directly changing a rich table cell containing a task list currently converts that cell to an ordinary paragraph without a conversion warning. Mark this case as failed until protection or a warning is implemented.
|
||||
- A rich table cell containing a task list now accepts safe source edits to task text and checked state while keeping the list structure. If an edit cannot be mapped back safely, such as a table shape change, the editor reverts that edit and explains why; the user can change the structure in rich text view. Other rich-only structures still require fixture-by-fixture verification before declaring D fully met.
|
||||
- A real Windows 10 / Chrome 154 check and save-and-reopen coverage for all fixtures have not been completed. No real cross-version test has been run; that test is a release gate if a storage or cross-version reading change is introduced. Do not claim Issue #469 meets the full lossless criteria on the current evidence.
|
||||
|
||||
Implementation references: [`editor-mode-content.ts`](../apps/web/src/components/editor/editor-mode-content.ts), [`useEditorMarkdownMode.ts`](../apps/web/src/components/editor/useEditorMarkdownMode.ts), [`content.ts`](../packages/shared/src/content.ts), and [`memo-service.ts`](../apps/api/src/memo-service.ts).
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
- 当前保存链路同时存储 `contentJson` 和 `contentMarkdown`。源码视图保存时发送用户原文;富文本视图保存时服务端从 JSON 生成 Markdown。因此 C 不承诺富文本编辑后的源码逐字相同。
|
||||
- 现有单元测试覆盖普通 `[`、`]`、`&` 的可读源码、危险转义保留、未编辑源码的快照恢复、未编辑的复杂表格单元格以及部分光标映射;本地浏览器验证了普通标点后的连续输入。
|
||||
- D 尚未全面满足:直接改动含待办列表的富文本表格单元格时,当前解析会把该单元格降为普通段落,且尚无转换提示。此场景在补齐保护或提示前必须标记失败。
|
||||
- 含待办列表的富文本表格单元格现在可安全接收源码中的待办文字修改和勾选状态切换,并保留原有列表结构。若修改无法安全映射回富文本,例如改变表格形状,编辑器撤回该次修改并给出说明;用户可在富文本视图中修改结构。其他富文本专有结构仍须按上述样本逐项验证,不能仅凭该案例宣称 D 全面通过。
|
||||
- Windows 10 / Chrome 154 的真实复验和全部样本的保存后重开尚未完成。真实旧版本跨版本验证也尚未完成;如引入存储或跨版本读取变更,须以此作为发布门禁。现有证据不足以宣称 Issue #469 已满足完整无损验收。
|
||||
|
||||
相关实现:[`editor-mode-content.ts`](../apps/web/src/components/editor/editor-mode-content.ts)、[`useEditorMarkdownMode.ts`](../apps/web/src/components/editor/useEditorMarkdownMode.ts)、[`content.ts`](../packages/shared/src/content.ts)、[`memo-service.ts`](../apps/api/src/memo-service.ts)。
|
||||
@@ -1980,6 +1980,7 @@ export const enUS = {
|
||||
dragHandle: "Drag block",
|
||||
noteBodyAria: "Note body",
|
||||
markdownSourceAria: "Markdown source",
|
||||
richTableSourceProtection: "This table cell has rich content that Markdown cannot fully represent. The last change was not applied. Edit this structure in rich text view; source outside the cell remains editable.",
|
||||
loading: "Loading",
|
||||
emptySelection: "Select or create a note",
|
||||
saveState: {
|
||||
|
||||
@@ -1980,6 +1980,7 @@ export const ja = {
|
||||
dragHandle: "ブロックをドラッグ",
|
||||
noteBodyAria: "ノート本文",
|
||||
markdownSourceAria: "Markdown ソース",
|
||||
richTableSourceProtection: "この表のセルには Markdown で完全に表現できないリッチテキスト構造があります。直前の変更は適用されませんでした。この構造はリッチテキスト表示で編集してください。セル以外のソースは編集できます。",
|
||||
loading: "読み込み中",
|
||||
emptySelection: "ノートを選択または作成",
|
||||
saveState: {
|
||||
|
||||
@@ -1980,6 +1980,7 @@ export const zhCN = {
|
||||
dragHandle: "拖动块",
|
||||
noteBodyAria: "笔记正文",
|
||||
markdownSourceAria: "Markdown 源码",
|
||||
richTableSourceProtection: "这处表格单元格含有 Markdown 无法完整表示的富文本结构。刚才的修改未应用;请在富文本视图中修改该结构,表格外的源码仍可编辑。",
|
||||
loading: "加载中",
|
||||
emptySelection: "选择或新建一条笔记",
|
||||
saveState: {
|
||||
|
||||
Reference in new issue
Block a user