feat: consolidate local-first mnote web runtime
This commit is contained in:
@@ -1,12 +1,45 @@
|
||||
import {
|
||||
localMarkdownDocumentIdFromRelativePath,
|
||||
localMarkdownRelativePathFromDocumentId,
|
||||
localizeTiptapAssetUrls,
|
||||
} from './document-tiptap-conversion-runtime.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} MNoteWorkspacePath
|
||||
* @property {'mnote.workspace_path.v1'} schema
|
||||
* @property {string} workspaceId
|
||||
* @property {string} sourceKind
|
||||
* @property {string} rootUri
|
||||
* @property {string} relativePath
|
||||
* @property {string} documentId
|
||||
* @property {string} objectIdentity
|
||||
* @property {string} assetId
|
||||
* @property {string} resourceKind
|
||||
*
|
||||
* @typedef {Object} MNoteOpenEditorEntry
|
||||
* @property {string} objectIdentity
|
||||
* @property {MNoteWorkspacePath|null} workspacePath
|
||||
* @property {'primary'|'secondary'} paneRole
|
||||
* @property {string} editorKind
|
||||
* @property {boolean} active
|
||||
* @property {string} dirtyState
|
||||
* @property {boolean} preview
|
||||
* @property {boolean} pinned
|
||||
* @property {number} lastActiveAt
|
||||
*
|
||||
* @typedef {Object} MNoteOpenEditorsSnapshot
|
||||
* @property {'mnote.open_editors_snapshot.v1'} schema
|
||||
* @property {number} generatedAt
|
||||
* @property {string} activeObjectIdentity
|
||||
* @property {MNoteOpenEditorEntry[]} editors
|
||||
* @property {{primary: Object, secondary: Object}} groups
|
||||
*/
|
||||
|
||||
export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
const {
|
||||
loadRuntime,
|
||||
createEditorViewBinding,
|
||||
ensureLocalFolderEventChannel,
|
||||
unmountEditorViewBinding,
|
||||
setStatus,
|
||||
documentSessionRegistry,
|
||||
@@ -62,9 +95,31 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
return 'file';
|
||||
};
|
||||
|
||||
const resourceTabWidthType = (entry) => {
|
||||
if (!entry) return '';
|
||||
const kind = String(entry.kind || '').trim();
|
||||
if (kind === 'office') {
|
||||
const badgeKind = String(entry.badgeKind || resourceTabBadgeKind(entry, kind) || '').trim();
|
||||
if (badgeKind === 'ppt') return 'ppt';
|
||||
if (badgeKind === 'sheet') return 'excel';
|
||||
return 'word';
|
||||
}
|
||||
if (kind === 'pdf') return 'pdf';
|
||||
if (kind === 'mindmap') return 'mindmap';
|
||||
if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'markdown';
|
||||
return '';
|
||||
};
|
||||
|
||||
const currentWebShellWorkspaceId = () => {
|
||||
const explicit = typeof currentWorkspaceId === 'function' ? String(currentWorkspaceId() || '').trim() : '';
|
||||
if (explicit) return explicit;
|
||||
const fromBody = document.body?.dataset?.workspaceId || '';
|
||||
if (fromBody) return String(fromBody).trim();
|
||||
const shell = document.querySelector('.document-shell[data-workspace-id], [data-document-pane="true"][data-pane-role="primary"][data-pane-workspace-id]');
|
||||
if (shell instanceof HTMLElement) {
|
||||
const value = shell.getAttribute('data-workspace-id') || shell.getAttribute('data-pane-workspace-id') || '';
|
||||
if (value) return value.trim();
|
||||
}
|
||||
try {
|
||||
return currentUrl().searchParams.get('workspaceId') || '';
|
||||
} catch (_) {
|
||||
@@ -82,6 +137,80 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const documentIdForPane = (paneRole) => {
|
||||
const role = normalizePaneRole(paneRole);
|
||||
const nodes = resourceTabHostNodes(role);
|
||||
const fromTab = String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim();
|
||||
if (fromTab) return fromTab;
|
||||
const pane = document.querySelector(`[data-document-pane="true"][data-pane-role="${role}"]`);
|
||||
if (pane instanceof HTMLElement) {
|
||||
const fromPane = String(pane.getAttribute('data-pane-document-id') || '').trim();
|
||||
if (fromPane) return fromPane;
|
||||
const root = pane.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-document-id], .document-shell[data-document-id]');
|
||||
if (root instanceof HTMLElement) {
|
||||
const fromRoot = String(root.getAttribute('data-document-id') || '').trim();
|
||||
if (fromRoot) return fromRoot;
|
||||
}
|
||||
}
|
||||
return role === 'primary' ? currentWebShellDocumentId() : '';
|
||||
};
|
||||
|
||||
const currentWebShellSourceKind = () => {
|
||||
try {
|
||||
return currentUrl().searchParams.get('sourceKind') || '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const currentWebShellRootUri = () => {
|
||||
try {
|
||||
return currentUrl().searchParams.get('rootUri') || '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const buildWorkspacePath = ({
|
||||
workspacePath,
|
||||
workspaceId,
|
||||
sourceKind,
|
||||
rootUri,
|
||||
relativePath,
|
||||
documentId,
|
||||
objectIdentity,
|
||||
assetId,
|
||||
resourceKind,
|
||||
} = {}) => {
|
||||
const fromInput = workspacePath && typeof workspacePath === 'object' ? workspacePath : null;
|
||||
if (fromInput && String(fromInput.schema || '') === 'mnote.workspace_path.v1') {
|
||||
return {
|
||||
schema: 'mnote.workspace_path.v1',
|
||||
workspaceId: String(fromInput.workspaceId || workspaceId || '').trim(),
|
||||
sourceKind: String(fromInput.sourceKind || sourceKind || '').trim(),
|
||||
rootUri: String(fromInput.rootUri || rootUri || '').trim(),
|
||||
relativePath: String(fromInput.relativePath || fromInput.localRelativePath || relativePath || '').trim(),
|
||||
documentId: String(fromInput.documentId || documentId || '').trim(),
|
||||
objectIdentity: fromInput.objectIdentity ?? String(objectIdentity || '').trim(),
|
||||
assetId: String(fromInput.assetId || assetId || '').trim(),
|
||||
resourceKind: String(fromInput.resourceKind || fromInput.objectKind || resourceKind || '').trim(),
|
||||
};
|
||||
}
|
||||
const id = String(objectIdentity || documentId || assetId || relativePath || '').trim();
|
||||
if (!id) return null;
|
||||
return {
|
||||
schema: 'mnote.workspace_path.v1',
|
||||
workspaceId: String(workspaceId || '').trim(),
|
||||
sourceKind: String(sourceKind || '').trim(),
|
||||
rootUri: String(rootUri || '').trim(),
|
||||
relativePath: String(relativePath || '').trim(),
|
||||
documentId: String(documentId || '').trim(),
|
||||
objectIdentity: String(objectIdentity || '').trim(),
|
||||
assetId: String(assetId || '').trim(),
|
||||
resourceKind: String(resourceKind || '').trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeResourceTabKind = (input) => {
|
||||
const kind = String(input?.kind || '').trim().toLowerCase();
|
||||
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
|
||||
@@ -121,7 +250,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
|
||||
const setResourceTabLastActive = (key) => {
|
||||
const entry = resourceTabRegistry.get(String(key || '').trim());
|
||||
if (entry?.session) entry.session.lastActiveAt = Date.now();
|
||||
if (!entry) return;
|
||||
const timestamp = Date.now();
|
||||
entry.lastActiveAt = timestamp;
|
||||
if (entry.session) entry.session.lastActiveAt = timestamp;
|
||||
};
|
||||
|
||||
const removeFromResourceTabMru = (paneRole, key) => {
|
||||
@@ -140,6 +272,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
|
||||
const resourceTabCloseGuardReason = (session) => {
|
||||
if (!session) return '';
|
||||
const bufferDirtyState = String(session.bufferDirtyState || '').trim();
|
||||
if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') {
|
||||
return bufferDirtyState;
|
||||
}
|
||||
const hasUnsavedChanges = session.dirty
|
||||
|| Boolean(session.saveTimer)
|
||||
|| sessionHasRecentLocalInput(session)
|
||||
@@ -150,7 +286,49 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const syncResourceTabCloseGuard = (entry) => {
|
||||
const documentSessionForPane = (paneRole, documentId) => {
|
||||
const id = String(documentId || '').trim();
|
||||
if (!id || !documentSessionRegistry || typeof documentSessionRegistry.values !== 'function') return null;
|
||||
const role = normalizePaneRole(paneRole);
|
||||
return Array.from(documentSessionRegistry.values()).find((session) => (
|
||||
session
|
||||
&& session.sessionKind !== 'resource'
|
||||
&& String(session.documentId || '').trim() === id
|
||||
&& Array.from(session.views?.values?.() || []).some((view) => normalizePaneRole(view?.runtimeDescriptor?.paneRole) === role)
|
||||
)) || Array.from(documentSessionRegistry.values()).find((session) => (
|
||||
session
|
||||
&& session.sessionKind !== 'resource'
|
||||
&& String(session.documentId || '').trim() === id
|
||||
)) || null;
|
||||
};
|
||||
|
||||
const documentSessionDirtyState = (session) => {
|
||||
if (!session) return '';
|
||||
const bufferDirtyState = String(session.bufferDirtyState || '').trim();
|
||||
if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') return bufferDirtyState;
|
||||
if (session.hasExternalConflict) return 'ExternalModified';
|
||||
if (session.dirty || Boolean(session.saveTimer) || sessionHasRecentLocalInput(session)) return 'Dirty';
|
||||
if (session.saving) return 'Saving';
|
||||
return '';
|
||||
};
|
||||
|
||||
const resourceTabBufferStateUrl = (entry) => {
|
||||
const session = entry?.session;
|
||||
if (!session || session.sourceKind !== 'local_folder') return null;
|
||||
const relativePath = String(session.resourcePath || entry?.workspacePath?.relativePath || '').trim();
|
||||
const rootUri = String(session.rootUri || entry?.workspacePath?.rootUri || '').trim();
|
||||
if (!relativePath || !rootUri) return null;
|
||||
const url = new URL('/api/documents/buffer-state', window.location.origin);
|
||||
url.searchParams.set('documentId', String(session.documentId || entry.objectIdentity || ''));
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
url.searchParams.set('relativePath', relativePath);
|
||||
const workspaceId = String(session.workspaceId || entry?.workspacePath?.workspaceId || '').trim();
|
||||
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
||||
return url;
|
||||
};
|
||||
|
||||
const applyResourceTabCloseGuard = (entry) => {
|
||||
if (!entry?.tab || !(entry.tab instanceof HTMLElement)) return;
|
||||
const reason = resourceTabCloseGuardReason(entry.session);
|
||||
if (reason) {
|
||||
@@ -162,6 +340,30 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshResourceTabBufferState = async (entry) => {
|
||||
const url = resourceTabBufferStateUrl(entry);
|
||||
if (!url || !entry?.session) return null;
|
||||
try {
|
||||
const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true || !payload.result) return null;
|
||||
const dirtyState = String(payload.result.dirtyState || '').trim();
|
||||
if (dirtyState) entry.session.bufferDirtyState = dirtyState;
|
||||
if (typeof payload.result.fileVersion === 'string' && payload.result.fileVersion.trim()) {
|
||||
entry.session.fileVersion = payload.result.fileVersion.trim();
|
||||
}
|
||||
applyResourceTabCloseGuard(entry);
|
||||
return payload.result;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const syncResourceTabCloseGuard = (entry) => {
|
||||
applyResourceTabCloseGuard(entry);
|
||||
void refreshResourceTabBufferState(entry);
|
||||
};
|
||||
|
||||
const syncResourceSessionTabGuards = (session) => {
|
||||
if (!session || session.sessionKind !== 'resource') return;
|
||||
resourceTabRegistry.forEach((entry) => {
|
||||
@@ -169,52 +371,126 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
});
|
||||
};
|
||||
|
||||
const openEditorsSnapshotEntry = (entry, key) => ({
|
||||
objectIdentity: String(entry?.objectIdentity || key || '').trim(),
|
||||
title: String(entry?.title || '资源').trim() || '资源',
|
||||
kind: normalizeResourceTabKind(entry),
|
||||
badgeKind: entry?.tab instanceof HTMLElement
|
||||
? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim()
|
||||
: resourceTabBadgeKind(entry, entry?.kind),
|
||||
active: entry?.tab instanceof HTMLElement
|
||||
const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
|
||||
const active = entry?.tab instanceof HTMLElement
|
||||
? entry.tab.getAttribute('aria-selected') === 'true'
|
||||
: false,
|
||||
dirtyGuard: resourceTabCloseGuardReason(entry?.session),
|
||||
assetId: String(entry?.assetId || entry?.session?.assetId || '').trim(),
|
||||
path: String(entry?.path || entry?.session?.resourcePath || '').trim(),
|
||||
});
|
||||
: false;
|
||||
const documentId = String(entry?.documentId || entry?.ownerDocumentId || entry?.session?.ownerDocumentId || '').trim();
|
||||
const workspaceId = String(entry?.workspaceId || entry?.session?.workspaceId || currentWebShellWorkspaceId() || '').trim();
|
||||
const sourceKind = String(entry?.sourceKind || entry?.session?.sourceKind || currentWebShellSourceKind() || '').trim();
|
||||
const rootUri = String(entry?.rootUri || entry?.session?.rootUri || currentWebShellRootUri() || '').trim();
|
||||
const relativePath = String(entry?.path || entry?.session?.resourcePath || '').trim();
|
||||
const objectIdentity = String(entry?.objectIdentity || key || '').trim();
|
||||
const assetId = String(entry?.assetId || entry?.session?.assetId || '').trim();
|
||||
const kind = normalizeResourceTabKind(entry);
|
||||
const dirtyState = resourceTabCloseGuardReason(entry?.session);
|
||||
return {
|
||||
objectIdentity,
|
||||
workspacePath: buildWorkspacePath({
|
||||
workspaceId,
|
||||
sourceKind,
|
||||
rootUri,
|
||||
relativePath,
|
||||
documentId,
|
||||
objectIdentity,
|
||||
assetId,
|
||||
resourceKind: kind,
|
||||
workspacePath: entry?.workspacePath,
|
||||
}),
|
||||
paneRole: normalizePaneRole(entry?.paneRole),
|
||||
documentId,
|
||||
workspaceId,
|
||||
title: String(entry?.title || '资源').trim() || '资源',
|
||||
kind,
|
||||
editorKind: kind,
|
||||
badgeKind: entry?.tab instanceof HTMLElement
|
||||
? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim()
|
||||
: resourceTabBadgeKind(entry, entry?.kind),
|
||||
active,
|
||||
dirtyState,
|
||||
dirtyGuard: dirtyState,
|
||||
assetId,
|
||||
path: relativePath,
|
||||
lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
|
||||
preview: false,
|
||||
pinned: false,
|
||||
};
|
||||
};
|
||||
|
||||
const buildOpenEditorsSnapshot = () => {
|
||||
const generatedAt = Date.now();
|
||||
const pageEntries = ['primary', 'secondary'].map((paneRole) => {
|
||||
const nodes = resourceTabHostNodes(paneRole);
|
||||
const documentId = documentIdForPane(paneRole);
|
||||
const workspaceId = String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim();
|
||||
const sourceKind = currentWebShellSourceKind();
|
||||
const rootUri = currentWebShellRootUri();
|
||||
const relativePath = sourceKind === 'local_folder' ? localMarkdownRelativePathFromDocumentId(documentId) : '';
|
||||
const objectIdentity = `page:${paneRole}`;
|
||||
const active = nodes.pageTab instanceof HTMLElement
|
||||
? nodes.pageTab.getAttribute('aria-selected') === 'true'
|
||||
: false;
|
||||
const pageSession = documentSessionForPane(paneRole, documentId);
|
||||
const dirtyState = documentSessionDirtyState(pageSession);
|
||||
const pageTitle = nodes.pageTab instanceof HTMLElement
|
||||
? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim()
|
||||
: '页面';
|
||||
return {
|
||||
objectIdentity: `page:${paneRole}`,
|
||||
objectIdentity,
|
||||
workspacePath: buildWorkspacePath({
|
||||
workspaceId,
|
||||
sourceKind,
|
||||
rootUri,
|
||||
relativePath,
|
||||
documentId,
|
||||
objectIdentity,
|
||||
assetId: '',
|
||||
resourceKind: 'page',
|
||||
}),
|
||||
paneRole,
|
||||
documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim(),
|
||||
workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(),
|
||||
documentId,
|
||||
workspaceId,
|
||||
title: pageTitle || '页面',
|
||||
kind: 'page',
|
||||
editorKind: 'page',
|
||||
badgeKind: 'code',
|
||||
active: nodes.pageTab instanceof HTMLElement
|
||||
? nodes.pageTab.getAttribute('aria-selected') === 'true'
|
||||
: false,
|
||||
dirtyGuard: '',
|
||||
active,
|
||||
dirtyState,
|
||||
dirtyGuard: dirtyState,
|
||||
lastActiveAt: active ? generatedAt : 0,
|
||||
preview: false,
|
||||
pinned: true,
|
||||
};
|
||||
}).filter((entry) => entry.paneRole === 'primary' || entry.documentId || document.querySelector('[data-document-pane="true"][data-pane-role="secondary"][data-pane-visible="true"]'));
|
||||
const resources = [];
|
||||
resourceTabRegistry.forEach((entry, key) => {
|
||||
resources.push(openEditorsSnapshotEntry(entry, key));
|
||||
resources.push(openEditorsSnapshotEntry(entry, key, generatedAt));
|
||||
});
|
||||
const active = [...pageEntries, ...resources].find((entry) => entry.active);
|
||||
const editors = [...pageEntries, ...resources];
|
||||
const groupForPane = (paneRole) => {
|
||||
const role = normalizePaneRole(paneRole);
|
||||
const groupEditors = editors.filter((entry) => normalizePaneRole(entry.paneRole) === role);
|
||||
const groupResources = resources.filter((entry) => normalizePaneRole(entry.paneRole) === role);
|
||||
const groupActive = groupEditors.find((entry) => entry.active);
|
||||
return {
|
||||
paneRole: role,
|
||||
activeObjectIdentity: groupActive?.objectIdentity || '',
|
||||
editors: groupEditors,
|
||||
resourceEditors: groupResources,
|
||||
};
|
||||
};
|
||||
const groups = {
|
||||
primary: groupForPane('primary'),
|
||||
secondary: groupForPane('secondary'),
|
||||
};
|
||||
const activeObjectIdentity = groups.primary.activeObjectIdentity || groups.secondary.activeObjectIdentity || '';
|
||||
return {
|
||||
schema: 'mnote.open_editors_snapshot.v1',
|
||||
generatedAt: Date.now(),
|
||||
activeObjectIdentity: active?.objectIdentity || '',
|
||||
editors: [...pageEntries, ...resources],
|
||||
generatedAt,
|
||||
activeObjectIdentity,
|
||||
editors,
|
||||
resourceEditors: resources,
|
||||
groups,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -295,6 +571,29 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const syncActiveResourceWidthType = (activeEntry, paneRole = 'primary') => {
|
||||
const role = normalizePaneRole(paneRole);
|
||||
const widthType = role === 'primary' ? resourceTabWidthType(activeEntry) : '';
|
||||
const targetNodes = [document.documentElement, document.body].filter((node) => node instanceof HTMLElement);
|
||||
targetNodes.forEach((node) => {
|
||||
if (widthType) node.setAttribute('data-mnote-active-resource-width-type', widthType);
|
||||
else node.removeAttribute('data-mnote-active-resource-width-type');
|
||||
});
|
||||
const shell = document.querySelector(`.document-pane[data-pane-role="${role}"] .document-shell`);
|
||||
if (shell instanceof HTMLElement) {
|
||||
if (widthType) shell.setAttribute('data-mnote-active-resource-width-type', widthType);
|
||||
else shell.removeAttribute('data-mnote-active-resource-width-type');
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('mnote:active-resource-tab-changed', {
|
||||
detail: {
|
||||
paneRole: role,
|
||||
active: Boolean(activeEntry),
|
||||
widthType,
|
||||
objectIdentity: String(activeEntry?.objectIdentity || '')
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const syncActiveResourceUrlState = (activeResource, paneRole = 'primary') => {
|
||||
const role = normalizePaneRole(paneRole);
|
||||
if (role !== 'primary') return;
|
||||
@@ -340,6 +639,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
|
||||
syncResourceTabCloseGuard(entry);
|
||||
});
|
||||
syncActiveResourceWidthType(activeEntry, role);
|
||||
if (activeEntry) markIntendedSlashRoot(activeEntry);
|
||||
if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null;
|
||||
if (role === 'primary') syncActiveResourceFileTreeRow(activeResource);
|
||||
@@ -399,10 +699,11 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
syncOpenEditorsSnapshot();
|
||||
};
|
||||
|
||||
const closeResourceTab = (objectIdentity) => {
|
||||
const closeResourceTab = async (objectIdentity) => {
|
||||
const key = String(objectIdentity || '').trim();
|
||||
const entry = resourceTabRegistry.get(key);
|
||||
if (!entry) return;
|
||||
await refreshResourceTabBufferState(entry);
|
||||
const guardReason = resourceTabCloseGuardReason(entry.session);
|
||||
if (guardReason) {
|
||||
console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`);
|
||||
@@ -489,6 +790,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
paneRole,
|
||||
title,
|
||||
kind,
|
||||
badgeKind: resourceTabBadgeKind(input, kind),
|
||||
tab,
|
||||
panel,
|
||||
view: null,
|
||||
@@ -497,6 +799,21 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
path: String(input.path || '').trim(),
|
||||
documentId: String(input.documentId || '').trim(),
|
||||
ownerDocumentId: String(input.ownerDocumentId || input.documentId || '').trim(),
|
||||
workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || '').trim(),
|
||||
sourceKind: String(input.sourceKind || currentWebShellSourceKind() || '').trim(),
|
||||
rootUri: String(input.rootUri || currentWebShellRootUri() || '').trim(),
|
||||
workspacePath: buildWorkspacePath({
|
||||
workspacePath: input.workspacePath,
|
||||
workspaceId: input.workspaceId || currentWebShellWorkspaceId(),
|
||||
sourceKind: input.sourceKind || currentWebShellSourceKind(),
|
||||
rootUri: input.rootUri || currentWebShellRootUri(),
|
||||
relativePath: input.path,
|
||||
documentId: input.documentId,
|
||||
objectIdentity,
|
||||
assetId: input.assetId,
|
||||
resourceKind: kind,
|
||||
}),
|
||||
lastActiveAt: 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -515,6 +832,12 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
}
|
||||
entry.mindmapRuntime = null;
|
||||
}
|
||||
if (entry.resourceWatchEventSource) {
|
||||
try {
|
||||
entry.resourceWatchEventSource.close();
|
||||
} catch (_) {}
|
||||
entry.resourceWatchEventSource = null;
|
||||
}
|
||||
if (entry.panel instanceof HTMLElement) entry.panel.replaceChildren();
|
||||
};
|
||||
|
||||
@@ -525,6 +848,80 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const localResourceStatUrl = (rootUri, path) => {
|
||||
const url = new URL('/api/local-folder/files/stat', window.location.origin);
|
||||
url.searchParams.set('rootUri', rootUri || '');
|
||||
url.searchParams.set('path', path || '');
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const withResourceReloadToken = (href) => {
|
||||
if (!href) return href;
|
||||
const url = new URL(href, window.location.origin);
|
||||
url.searchParams.set('mnoteResourceReload', String(Date.now()));
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const renderPassiveResourceMissing = (entry, message) => {
|
||||
if (!(entry?.panel instanceof HTMLElement)) return;
|
||||
entry.panel.setAttribute('data-mnote-resource-missing', 'true');
|
||||
entry.panel.innerHTML = '<div class="mnote-resource-tab-error" data-resource-tab-error="true" data-mnote-resource-missing="true"><div class="mnote-resource-tab-error-inner"><h1>资源已不可用</h1><p></p></div></div>';
|
||||
const text = entry.panel.querySelector('p');
|
||||
if (text instanceof HTMLElement) text.textContent = message || '本地资源文件已被删除或移动。';
|
||||
};
|
||||
|
||||
const reloadPassiveResourceTab = (entry) => {
|
||||
if (!(entry?.panel instanceof HTMLElement)) return;
|
||||
entry.panel.removeAttribute('data-mnote-resource-missing');
|
||||
const img = entry.panel.querySelector('img.mnote-resource-tab-image');
|
||||
if (img instanceof HTMLImageElement) {
|
||||
img.src = withResourceReloadToken(img.getAttribute('src') || img.src || '');
|
||||
return;
|
||||
}
|
||||
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
|
||||
if (frame instanceof HTMLIFrameElement) {
|
||||
frame.src = withResourceReloadToken(frame.getAttribute('src') || frame.src || '');
|
||||
}
|
||||
};
|
||||
|
||||
const installPassiveResourceWatch = (entry) => {
|
||||
if (!entry || entry.session || typeof window.EventSource !== 'function') return;
|
||||
const rootUri = String(entry.rootUri || '').trim();
|
||||
const path = String(entry.path || '').trim();
|
||||
if (!rootUri || !path) return;
|
||||
if (entry.resourceWatchEventSource) {
|
||||
try {
|
||||
entry.resourceWatchEventSource.close();
|
||||
} catch (_) {}
|
||||
entry.resourceWatchEventSource = null;
|
||||
}
|
||||
const url = new URL('/api/local-folder/events', window.location.origin);
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
url.searchParams.set('resourcePath', path);
|
||||
const eventSource = new EventSource(url.toString());
|
||||
entry.resourceWatchEventSource = eventSource;
|
||||
eventSource.addEventListener('ready', () => {
|
||||
if (entry.panel instanceof HTMLElement) entry.panel.setAttribute('data-mnote-resource-watch-ready', 'true');
|
||||
});
|
||||
eventSource.addEventListener('change', async () => {
|
||||
try {
|
||||
const response = await fetch(localResourceStatUrl(rootUri, path), {
|
||||
cache: 'no-store',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
const exists = Boolean(response.ok && payload?.ok === true && payload?.result?.exists);
|
||||
if (!exists) {
|
||||
renderPassiveResourceMissing(entry, '本地资源文件已被删除或移动。');
|
||||
return;
|
||||
}
|
||||
reloadPassiveResourceTab(entry);
|
||||
} catch (error) {
|
||||
renderPassiveResourceMissing(entry, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createResourceSession = (entry, input, readResult) => {
|
||||
const resourcePath = String(input.path || '');
|
||||
const tiptapDocument = localizeTiptapAssetUrls(
|
||||
@@ -577,6 +974,9 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
error: null,
|
||||
};
|
||||
documentSessionRegistry.set(session.key, session);
|
||||
if (typeof ensureLocalFolderEventChannel === 'function') {
|
||||
ensureLocalFolderEventChannel(session);
|
||||
}
|
||||
return session;
|
||||
};
|
||||
|
||||
@@ -644,6 +1044,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
img.src = href;
|
||||
img.alt = entry.title;
|
||||
}
|
||||
installPassiveResourceWatch(entry);
|
||||
return;
|
||||
}
|
||||
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
|
||||
@@ -652,6 +1053,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
frame.title = entry.title;
|
||||
frame.src = href;
|
||||
}
|
||||
installPassiveResourceWatch(entry);
|
||||
};
|
||||
|
||||
const refreshExistingOfficeResourceTab = (entry, input) => {
|
||||
|
||||
Reference in New Issue
Block a user