Implement local Markdown GFM parser and live refresh
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user