diff --git a/package-lock.json b/package-lock.json index 9a7e8bc7..fe073728 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "rdflib": "2.4.1", "solid-logic": "6.0.1-5", "solid-namespace": "^0.5.4", - "solid-ui": "5.0.2-4", + "solid-ui": "5.0.2-5", "source-pane": "4.0.1-0" }, "devDependencies": { @@ -2798,9 +2798,9 @@ "license": "MIT" }, "node_modules/@lezer/common": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.4.tgz", - "integrity": "sha512-xt3Q4z64xM+xhg4M7NXqw1N//1O74bUFo5kz6fwMeC4qwxSuRdwkYSmjcqCEEKYkNw5e67f2hcNqBoNERAEU0w==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.5.tgz", + "integrity": "sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==", "license": "MIT" }, "node_modules/@lezer/css": { @@ -2857,9 +2857,9 @@ } }, "node_modules/@lezer/lr": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz", - "integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==", + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.12.tgz", + "integrity": "sha512-On9HU4oMMiHPMkQDgoTnGahjgG5n/MkN38CgG77sMraKLL+3JBbeQNi1FcCXV9QRqwF9/BUXP3caHyJC1fBOTQ==", "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" @@ -5337,9 +5337,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.468", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.468.tgz", - "integrity": "sha512-8XdEp3sIqO+PzmVPxhzqHq/3+yCxp44m27cm2WY1sRn3ZhwKyKmqSi2hx6gEhmnOXmh8NUuYE00z6Onzn5m6DA==", + "version": "1.5.471", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.471.tgz", + "integrity": "sha512-Lo7jyyneHRYUQYM3WZ2rBEei9FcUIwValFPLifTbr4NTpc6oyUB+nN2rrOtbn3CE5pgz930mYxRJeD+glR7d6Q==", "dev": true, "license": "ISC" }, @@ -8133,9 +8133,9 @@ } }, "node_modules/lucide": { - "version": "1.64.0", - "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.64.0.tgz", - "integrity": "sha512-2k481p+2dnI88E5chX8D1niwJzar/0EMFZwS93E/umHVxl+Po5uoDIGuo64Ij3a35YaD1J6EsWulDzKaod5wOw==", + "version": "1.66.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.66.0.tgz", + "integrity": "sha512-Ngq8coV3XJ9S/DLTNnIWk9lmdJusoSnuuoO1rK7C0H9/VaV93LoWkv/u5GyUAJhL+rFesAd4gL3YG544bnH6gQ==", "license": "ISC" }, "node_modules/lz-string": { @@ -8641,9 +8641,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.61", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.61.tgz", - "integrity": "sha512-5NUN6QCxNFrS9oEA9RPS+d0AP+ooGBzzovY2JhIpDRHacPvOhHjXjUsUMX9qp4nLHZ+5jVgXB2jj/P4arGOE/g==", + "version": "2.0.63", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz", + "integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==", "dev": true, "license": "MIT", "engines": { @@ -9973,9 +9973,9 @@ "license": "MIT" }, "node_modules/solid-ui": { - "version": "5.0.2-4", - "resolved": "https://registry.npmjs.org/solid-ui/-/solid-ui-5.0.2-4.tgz", - "integrity": "sha512-lT6Grklrtd9eDn6Rt9HMRDstcMNmbb/7Vy0oOUOLVpA5njmq/+P1A3eNvkLMRqR9cCsXCrwCkAYqdbql+uazRQ==", + "version": "5.0.2-5", + "resolved": "https://registry.npmjs.org/solid-ui/-/solid-ui-5.0.2-5.tgz", + "integrity": "sha512-ZqvKeOIzZcSD31eItGCTjNVUY5PeoiotCE8PmK+JeWKGrtHLPSOh5Wz3nlJtJns8IZOmAqmFlmrhZJGNAkFcSQ==", "license": "MIT", "dependencies": { "@awesome.me/webawesome": "^3.9.0", @@ -9998,9 +9998,9 @@ "escape-html": "^1.0.3", "lit": "^3.3.3", "mime-types": "^3.0.2", - "pane-registry": "5.0.2-1", + "pane-registry": "5.0.2-2", "rdflib": "2.4.1", - "solid-logic": "6.0.1-4", + "solid-logic": "6.0.1-5", "solid-namespace": "^0.5.4", "uuid": "^14.0.0" }, @@ -10008,32 +10008,6 @@ "fsevents": "*" } }, - "node_modules/solid-ui/node_modules/pane-registry": { - "version": "5.0.2-1", - "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.2-1.tgz", - "integrity": "sha512-gDub2yjXejZ0JQSsEvDpUhff4e205+IIKV2iYJCbBBs65QiN/2a4wmpv2/NZJjoF6FADW+ndv8nSc6w3CxuYFg==", - "license": "MIT", - "dependencies": { - "rdflib": "2.4.1", - "solid-logic": "6.0.1-4" - } - }, - "node_modules/solid-ui/node_modules/solid-logic": { - "version": "6.0.1-4", - "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-4.tgz", - "integrity": "sha512-EVvS+5g91aUoONqGtM1vPnMimtwH6c/72td5b4qBRcudDjeRU/hycPg8p5abmx1YWZseLA1EFK76+E/t5bcPFw==", - "license": "MIT", - "dependencies": { - "@dokieli/web-access-control": "^1.1.0", - "@uvdsl/solid-oidc-client-browser": "^0.2.3", - "solid-namespace": "^0.5.4", - "vite": "^8.0.16", - "vitest": "^4.1.9" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/solidos-toolkit": { "version": "1.0.0-dev.2fb6be508170f3b28ca79d738aae71cd48f6d87e", "resolved": "https://registry.npmjs.org/solidos-toolkit/-/solidos-toolkit-1.0.0-dev.2fb6be508170f3b28ca79d738aae71cd48f6d87e.tgz", diff --git a/package.json b/package.json index 33404c5d..ec4f7173 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "rdflib": "2.4.1", "solid-logic": "6.0.1-5", "solid-namespace": "^0.5.4", - "solid-ui": "5.0.2-4", + "solid-ui": "5.0.2-5", "source-pane": "4.0.1-0" }, "overrides": { diff --git a/src/mainPage/index.ts b/src/mainPage/index.ts index 7f048012..595b848e 100644 --- a/src/mainPage/index.ts +++ b/src/mainPage/index.ts @@ -19,10 +19,17 @@ import { HUMAN_READABLE_PREVIEW_FRAME_NAME } from '../humanReadablePane' // Symbol used to stash the last render-relevant env snapshot on the outliner // so refreshUI can skip a full GotoSubject re-render when nothing changed. const LAST_RENDER_ENV_KEY = '__lastRenderEnvSignature' +let activeExplorerRefreshPending = false function renderEnvSignature (env?: RenderEnvironment): string { if (!env) return '' - return [env.layout, env.theme, env.inputMode].join('|') + return [ + env.layout, + env.theme, + env.inputMode, + env.viewport?.width, + env.viewport?.height + ].join('|') } function ensureMainContent () { @@ -91,6 +98,22 @@ export async function refreshUI (outliner: OutlineManager) { const previousSignature = (outliner as any)?.[LAST_RENDER_ENV_KEY] ?? '' const envChanged = currentSignature !== previousSignature + // Folder explorers adapt through CSS. Recreating one from the page's + // history entry discards its internal resource/pane selection on resize. + const themeUnchanged = currentSignature.split('|')[1] === previousSignature.split('|')[1] + if (envChanged && themeUnchanged && document.querySelector('#MainContent folder-pane-view')) { + if (activeExplorerRefreshPending) return + activeExplorerRefreshPending = true + await new Promise(resolve => requestAnimationFrame(() => resolve())) + try { + ;(outliner as OutlineManager & { refreshActiveExplorerPanes?: () => void }).refreshActiveExplorerPanes?.() + ;(outliner as any)[LAST_RENDER_ENV_KEY] = currentSignature + } finally { + activeExplorerRefreshPending = false + } + return + } + if (envChanged && store && typeof outliner?.GotoSubject === 'function') { // Re-render what the current history entry displays without changing the // URL or losing that it came from the account menu. diff --git a/src/outline/manager.js b/src/outline/manager.js index 16d1827e..8952d277 100644 --- a/src/outline/manager.js +++ b/src/outline/manager.js @@ -300,6 +300,38 @@ export default function (context) { renderPaneIntoProvider(provider, subject, pane, explorerStates.get(provider)?.options || {}) } + this.refreshActiveExplorerPanes = function () { + const providers = Array.from(dom.querySelectorAll(EXPLORER_PROVIDER_TAG)) + for (const provider of providers) { + const subjectUri = provider.subjectUri + const pane = provider.pane + if (!subjectUri || !pane) continue + + const currentPane = provider.querySelector(':scope > .paneDiv') + if (!currentPane) continue + // Re-rendering the folder shell recreates its view and loses the resource + // selected inside it. Nested providers can refresh their active resource + // pane without replacing the shell. + if (currentPane.querySelector(':scope > folder-pane-view')) continue + + let paneElement + try { + paneElement = pane.render( + $rdf.sym(subjectUri), + context, + provider.paneRenderOptions || explorerStates.get(provider)?.options || {} + ) + } catch (error) { + UI.log.error('outline: Failed to refresh pane layout: ' + UI.utils.stackString(error)) + continue + } + + paneElement.classList.add('paneDiv') + currentPane.replaceWith(paneElement) + renderedPanes.set(provider, { pane, subjectUri }) + } + } + function composeExplorer (provider, subject, pane, paneElement) { const state = explorerStates.get(provider) UI.composeExplorer(provider, { diff --git a/test/unit/mainPage/index.test.ts b/test/unit/mainPage/index.test.ts index 52a841a0..8dfd9ee6 100644 --- a/test/unit/mainPage/index.test.ts +++ b/test/unit/mainPage/index.test.ts @@ -313,6 +313,19 @@ describe('browser navigation without a reload', () => { expect(GotoSubject).toHaveBeenCalledOnce() }) + it('refreshes when the viewport changes within the same layout', async () => { + const GotoSubject = await setupLoaded() + const outliner = getOutliner() + outliner.context = { + session: { store }, + environment: { ...environment, viewport: { width: 1279, height: 800 } } + } + + await refreshUI(outliner) + + expect(GotoSubject).toHaveBeenCalledOnce() + }) + it('falls back to popstate when the Navigation API is unavailable', async () => { Reflect.deleteProperty(window, 'navigation') vi.resetModules() diff --git a/test/unit/outline/manager.test.ts b/test/unit/outline/manager.test.ts index db0ae259..70213f5a 100644 --- a/test/unit/outline/manager.test.ts +++ b/test/unit/outline/manager.test.ts @@ -407,6 +407,63 @@ describe('manager', () => { expect(provider.querySelector('.paneDiv')).not.toBe(paneElement) }) + it('re-renders the selected pane when the layout changes without replacing its explorer', async () => { + const subject = sym(`${window.location.origin}/layout-dependent.ttl`) + const pane = { + ...MockPane, + render: (_subject: NamedNode, context: { environment?: { layout?: string } }) => { + const element = document.createElement('div') + element.className = 'layout-dependent-pane' + element.dataset.layout = context.environment?.layout ?? 'desktop' + return element + } + } + const context = { + dom: document, + session: { paneRegistry: { list: [pane], byName: findPane }, store }, + environment: { layout: 'desktop' } + } + const manager = new OutlineManager(context) + const outlineView = document.createElement('div') + outlineView.id = 'OutlineView' + document.body.appendChild(outlineView) + const provider = manager.propertyTable(subject, undefined, pane, {}) + outlineView.appendChild(provider) + + await vi.waitFor(() => expect(provider.querySelector('.layout-dependent-pane')).not.toBeNull()) + expect(provider.querySelector('.layout-dependent-pane')?.dataset.layout).toBe('desktop') + + context.environment.layout = 'mobile' + manager.refreshActiveExplorerPanes() + + await vi.waitFor(() => expect(provider.querySelector('.layout-dependent-pane')?.dataset.layout).toBe('mobile')) + expect(provider.subjectUri).toBe(subject.uri) + expect(provider.pane).toBe(pane) + }) + + it('does not re-render the folder shell while refreshing a nested resource pane', async () => { + const folderPane = { ...MockPane, name: 'folder', render: vi.fn(MockPane.render) } + const subject = sym(`${window.location.origin}/storage/`) + const manager = new OutlineManager({ + dom: document, + session: { paneRegistry: { list: [folderPane], byName: findPane }, store }, + environment: { layout: 'desktop' } + }) + const provider = manager.propertyTable(subject, undefined, folderPane, {}) + document.body.appendChild(provider) + await vi.waitFor(() => expect(provider.querySelector('.paneDiv')).not.toBeNull()) + + const folderShell = document.createElement('folder-pane-view') + provider.querySelector('.paneDiv')?.appendChild(folderShell) + folderPane.render.mockClear() + + manager.context.environment.layout = 'mobile' + manager.refreshActiveExplorerPanes() + + expect(folderPane.render).not.toHaveBeenCalled() + expect(provider.querySelector('folder-pane-view')).toBe(folderShell) + }) + it('does not add a resource header for index.ttl#this', async () => { const provider = createExplorer(MockPane, { solo: true }, sym(`${window.location.origin}/addressbook/index.ttl#this`)) await vi.waitFor(() => expect(provider.querySelector('.paneDiv')).not.toBeNull())