Implement local Markdown GFM parser and live refresh

This commit is contained in:
lix-2026
2026-05-08 10:57:24 +08:00
parent e082e71b5d
commit b183d24ba5
8 changed files with 1354 additions and 745 deletions
+118 -4
View File
@@ -323,6 +323,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const SAVE_EVENT = `${EVENT_PREFIX}:save-request`;
const READY_EVENT = `${EVENT_PREFIX}:ready`;
const ERROR_EVENT = `${EVENT_PREFIX}:error`;
const COMMAND_EVENT = `${EVENT_PREFIX}:command`;
const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1';
const parseJsonScript = (id) => {
const node = document.getElementById(id);
@@ -644,11 +646,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const pageBody = aggregate.body || {};
const permissions = aggregate.head?.permissions || {};
const conflictDetectionKey = typeof pageBody.conflictDetectionKey === 'string'
? pageBody.conflictDetectionKey
: typeof pageBody.conflict_detection_key === 'string'
? pageBody.conflict_detection_key
const conflictDetectionKeyFromBody = (body) => typeof body?.conflictDetectionKey === 'string'
? body.conflictDetectionKey
: typeof body?.conflict_detection_key === 'string'
? body.conflict_detection_key
: null;
const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody);
const revisionFromConflictKey = (value) => {
const match = String(value || '').match(/:(\d+)$/);
return match ? Number(match[1]) : null;
@@ -688,6 +691,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
let saveTimer = 0;
let lastSavedSerialized = '';
let hasPendingLocalChanges = false;
let suppressNextHostSyncChange = false;
let localExternalPollTimer = 0;
let localExternalPollInFlight = false;
let lastExternalConflictDetectionKey = editorMeta.conflictDetectionKey || '';
const normalizeBridgeValue = (value) => {
if (value instanceof Map) {
const out = {};
@@ -719,7 +727,14 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
currentEditorText(),
);
const serialized = JSON.stringify(tiptapDocument);
if (suppressNextHostSyncChange && serialized === lastSavedSerialized) {
suppressNextHostSyncChange = false;
hasPendingLocalChanges = false;
setStatus('saved');
return;
}
if (serialized === lastSavedSerialized) {
hasPendingLocalChanges = false;
setStatus('saved');
return;
}
@@ -751,7 +766,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (Number.isInteger(saved.revision)) editorMeta.revision = saved.revision;
if (typeof saved.conflict_detection_key === 'string') editorMeta.conflictDetectionKey = saved.conflict_detection_key;
if (typeof saved.conflictDetectionKey === 'string') editorMeta.conflictDetectionKey = saved.conflictDetectionKey;
if (editorMeta.conflictDetectionKey) lastExternalConflictDetectionKey = editorMeta.conflictDetectionKey;
lastSavedSerialized = serialized;
hasPendingLocalChanges = false;
if (typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
window.__mnoteRecordPageHistorySnapshot('save', {
wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length,
@@ -767,7 +784,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const queueSave = (event) => {
const payload = normalizeEnvelopePayload(event);
if (!payload) return;
if (suppressNextHostSyncChange) {
const tiptapDocument = toTiptapDocument(
payload?.tiptapDocument || payload?.editorDocument || payload?.content,
currentEditorText(),
);
if (JSON.stringify(tiptapDocument) === lastSavedSerialized) {
suppressNextHostSyncChange = false;
hasPendingLocalChanges = false;
setStatus('saved');
return;
}
}
if (saveTimer) window.clearTimeout(saveTimer);
hasPendingLocalChanges = true;
setStatus('dirty');
saveTimer = window.setTimeout(() => {
saveTimer = 0;
@@ -785,6 +815,79 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
root.addEventListener(CHANGE_EVENT, queueSave);
root.addEventListener(SAVE_EVENT, queueSave);
const pageAggregateUrl = () => {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}`, window.location.origin);
url.searchParams.set('sourceKind', bootstrap.sourceKind || 'local_folder');
if (bootstrap.rootUri) url.searchParams.set('rootUri', bootstrap.rootUri);
return url;
};
const dispatchReplaceContent = (nextAggregate) => {
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
const nextTiptapDocument = toTiptapDocument(nextBody.content);
editorMeta.revision = nextRevision;
editorMeta.conflictDetectionKey = nextConflictKey;
lastExternalConflictDetectionKey = nextConflictKey || '';
lastSavedSerialized = JSON.stringify(nextTiptapDocument);
hasPendingLocalChanges = false;
suppressNextHostSyncChange = true;
clearEmbeddedLocalDraft();
root.dispatchEvent(new CustomEvent(COMMAND_EVENT, {
bubbles: true,
detail: {
protocol: BRIDGE_PROTOCOL,
runtime: 'mnote-leptos-tiptap-spike',
version: '1.1.0',
source: 'mnote-web-local-folder-watch',
event: COMMAND_EVENT,
payload: {
command: 'replaceContent',
documentId: bootstrap.documentId,
workspaceId: bootstrap.workspaceId,
title: nextAggregate?.head?.title || mountOptions.title,
content: nextTiptapDocument,
revision: editorMeta.revision,
conflictDetectionKey: editorMeta.conflictDetectionKey,
readOnly: Boolean(nextPermissions.readOnly),
},
},
}));
setStatus('synced-external-change');
};
const pollLocalMarkdownExternalChange = async () => {
if (bootstrap.sourceKind !== 'local_folder' || !bootstrap.rootUri || document.hidden) return;
if (localExternalPollInFlight) return;
localExternalPollInFlight = true;
try {
const response = await fetch(pageAggregateUrl().toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
if (!response.ok) return;
const payload = await response.json();
const nextAggregate = payload?.result;
const nextConflictKey = conflictDetectionKeyFromBody(nextAggregate?.body || {});
if (!nextConflictKey || !lastExternalConflictDetectionKey) {
lastExternalConflictDetectionKey = nextConflictKey || lastExternalConflictDetectionKey;
return;
}
if (nextConflictKey === lastExternalConflictDetectionKey) return;
if (hasPendingLocalChanges || saveTimer) {
setStatus('external-change-conflict', '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突');
return;
}
dispatchReplaceContent(nextAggregate);
} catch (error) {
console.warn('mnote local folder 外部更新检测失败', error);
} finally {
localExternalPollInFlight = false;
}
};
const start = async () => {
setStatus('loading-assets');
const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' });
@@ -800,11 +903,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
await runtime.default(wasmUrl);
clearEmbeddedLocalDraft();
const mountId = runtime.mount(root, mountOptions);
lastSavedSerialized = JSON.stringify(mountOptions.content);
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
if (typeof window.__mnoteApplyPageOptionsToShell === 'function') {
window.__mnoteApplyPageOptionsToShell();
}
if (bootstrap.sourceKind === 'local_folder' && bootstrap.rootUri) {
void pollLocalMarkdownExternalChange();
localExternalPollTimer = window.setInterval(() => {
void pollLocalMarkdownExternalChange();
}, 1200);
}
setStatus('ready');
};
@@ -1530,6 +1640,10 @@ mod tests {
assert!(html.contains("asset.png"));
assert!(html.contains("data-row-kind=\"markdown\""));
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
assert!(html.contains("pollLocalMarkdownExternalChange"));
assert!(html.contains("/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}"));
assert!(html.contains("command: 'replaceContent'"));
assert!(html.contains("external-change-conflict"));
}
#[tokio::test]