diff --git a/components/simple-transcription/index.js b/components/simple-transcription/index.js index f9525978..e4e5d3cf 100644 --- a/components/simple-transcription/index.js +++ b/components/simple-transcription/index.js @@ -969,10 +969,10 @@ export default class SimpleTranscriptionInterface extends HTMLElement { /** * Resolve each item in `page.items` to a full Annotation via the vault. * Vault fetches for AnnotationPages return children as bare `{id, type}` - * refs — downstream tools need hydrated targets/selectors/bodies. Returns - * a shallow copy of the page with the items array replaced; errors on - * individual items fall back to the original ref so partial hydration - * still produces a usable payload. + * refs — downstream tools that opt into the hydrated payload need hydrated + * targets/selectors/bodies. Returns a shallow copy of the page with the + * items array replaced; errors on individual items fall back to the + * original ref so partial hydration still produces a usable payload. */ async #hydratePageItems(page) { if (!Array.isArray(page?.items) || page.items.length === 0) return page @@ -983,10 +983,81 @@ export default class SimpleTranscriptionInterface extends HTMLElement { return { ...page, items } } - async #buildTPENContext() { + /** + * Find the layer page entry that matches the active page in the URL. Used + * to source the column ordering and the canvas list for the lean + * TPEN_CONTEXT payload. + */ + #getActiveLayerPage() { + const pageInQuery = TPEN.screen?.pageInQuery + if (!pageInQuery) return null + return TPEN.activeProject?.layers + ?.flatMap(layer => layer.pages || []) + .find(p => p.id?.split('/').pop() === pageInQuery) ?? null + } + + #getActiveLayerCanvases() { + const pageInQuery = TPEN.screen?.pageInQuery + if (!pageInQuery) return [] + const layer = TPEN.activeProject?.layers + ?.find(l => l.pages?.some(p => p.id?.split('/').pop() === pageInQuery)) + return layer?.pages?.map(p => ({ id: p.target, label: p.label })) ?? [] + } + + /** + * Lean boot payload sent to every iframe tool on load. Carries identity + * fields and URIs only — tools that need annotation bodies should fetch + * `annotationPage` directly, and tools that need the fully-populated + * project or page should send `REQUEST_POPULATED_PROJECT` / + * `REQUEST_POPULATED_PAGE`. + * + * Note: each entry in `canvases` is `{ id, label }` where `id` is the + * **canvas IRI** for that page (i.e. `page.target`), not the page IRI + * itself. This matches Compare-Pages' usage (it fetches the IRI as a IIIF + * canvas). Tools that need the page id should derive it from the canvas + * IRI or request the populated page. + */ + #buildTPENContext() { + const project = TPEN.activeProject ?? null + const layerPage = this.#getActiveLayerPage() return { type: 'TPEN_CONTEXT', - project: TPEN.activeProject ?? null, + project: project ? { + id: project._id ?? project.id ?? null, + label: project.label ?? null, + slug: project.slug ?? null + } : null, + manifest: project?.manifest?.[0] ?? null, + canvas: this.#canvas?.id ?? this.#canvas?.['@id'] ?? null, + annotationPage: this.#page?.id ?? null, + currentLineId: this.#getCurrentLineId(), + columns: layerPage?.columns ?? [], + canvases: this.#getActiveLayerCanvases() + } + } + + /** + * Reply payload for `REQUEST_POPULATED_PROJECT`. Carries the full active + * project (layers, pages, columns, members, …). Lean `TPEN_CONTEXT` only + * carries project identity, so tools that need the full graph must ask + * for it explicitly. + */ + #buildPopulatedProject() { + return { + type: 'TPEN_POPULATED_PROJECT', + project: TPEN.activeProject ?? null + } + } + + /** + * Reply payload for `REQUEST_POPULATED_PAGE`. Carries the active page + * with items resolved to full Annotations via the vault, the full canvas + * object, and the current line id. TPEN-Prompts uses this for + * prompt-template rendering. + */ + async #buildPopulatedPage() { + return { + type: 'TPEN_POPULATED_PAGE', page: await this.#hydratePageItems(this.#page), canvas: this.#canvas ?? null, currentLineId: this.#getCurrentLineId() @@ -998,10 +1069,11 @@ export default class SimpleTranscriptionInterface extends HTMLElement { targetWindow.postMessage(message, this._iframeOrigin) } - async #sendTPENContextToTool(targetWindow = this.#activeToolIframe?.contentWindow) { - this.#postToTool(await this.#buildTPENContext(), targetWindow) - } - + // The TPEN ID token is the most sensitive message in this protocol. It is + // user-gated (only sent in reply to REQUEST_TPEN_ID_TOKEN), posted to the + // iframe origin only, and surfaced via a toast so the user always sees the + // grant. Do not send it unprompted, broadcast it with targetOrigin '*', or + // log it. #sendIdTokenToTool(targetWindow = this.#activeToolIframe?.contentWindow) { const idToken = TPEN.getAuthorization() @@ -1095,37 +1167,11 @@ export default class SimpleTranscriptionInterface extends HTMLElement { this._iframeOrigin = new URL(tool.url).origin iframe.addEventListener('load', () => { - const target = iframe.contentWindow - this.#sendTPENContextToTool(target) - - this.#postToTool({ - type: 'MANIFEST_CANVAS_ANNOTATIONPAGE_ANNOTATION', - manifest: TPEN.activeProject?.manifest?.[0] ?? '', - canvas: this.#canvas?.id ?? this.#canvas?.['@id'] ?? '', - annotationPage: this.#page?.id ?? '', - annotation: TPEN.activeLineIndex >= 0 - ? this.#page?.items?.[TPEN.activeLineIndex]?.id ?? null - : null, - columns: TPEN.activeProject?.layers - ?.flatMap(layer => layer.pages || []) - .find(p => p.id?.split('/').pop() === TPEN.screen?.pageInQuery)?.columns || [] - }, target) - - this.#postToTool({ - type: 'CANVASES', - canvases: TPEN.activeProject?.layers - ?.find(layer => layer.pages?.some(p => p.id?.split('/').pop() === TPEN.screen?.pageInQuery)) - ?.pages?.flatMap(p => ({ id: p.target, label: p.label })) ?? [] - }, target) - - this.#postToTool({ type: 'CURRENT_LINE_INDEX', lineId: this.#getCurrentLineId() }, target) + this.#postToTool(this.#buildTPENContext(), iframe.contentWindow) }) const sendLineSelection = () => { - const currentLineId = this.#getCurrentLineId() - this.#postToTool({ type: 'UPDATE_CURRENT_LINE', currentLineId }) - - this.#postToTool({ type: 'CURRENT_LINE_INDEX', lineId: currentLineId }) + this.#postToTool({ type: 'UPDATE_CURRENT_LINE', currentLineId: this.#getCurrentLineId() }) } this.#toolCleanup.onEvent(TPEN.eventDispatcher, 'tpen-transcription-previous-line', sendLineSelection) this.#toolCleanup.onEvent(TPEN.eventDispatcher, 'tpen-transcription-next-line', sendLineSelection) @@ -1143,39 +1189,45 @@ export default class SimpleTranscriptionInterface extends HTMLElement { this.checkMagnifierVisibility?.() } - #handleToolMessages(event) { + async #handleToolMessages(event) { // Validate message origin if iframe origin is set if (this._iframeOrigin && event.origin !== this._iframeOrigin) { return } - if (event.data?.type === 'REQUEST_TPEN_ID_TOKEN') { - this.#sendIdTokenToTool(event.source) - return - } - - // Handle incoming messages from tools - const lineId = event.data?.lineId ?? event.data?.lineid ?? event.data?.annotation // handle different casing and properties - - if (!lineId) return - - // Handle all line navigation message types - if (event.data?.type === "CURRENT_LINE_INDEX" || - event.data?.type === "RETURN_LINE_ID" || - event.data?.type === "SELECT_ANNOTATION" || - event.data?.type === "NAVIGATE_TO_LINE") { - // Tool is telling us to navigate to a specific line - // Line ID might be full URI or just the ID part - const lineIndex = this.#page?.items?.findIndex(item => { - const itemId = item.id ?? item['@id'] - // Match either full ID or just the last part after the last slash - return itemId === lineId || itemId?.endsWith?.(`/${lineId}`) || itemId?.split?.('/').pop() === lineId - }) - - if (lineIndex !== undefined && lineIndex !== -1) { - TPEN.activeLineIndex = lineIndex - this.updateLines() + const type = event.data?.type + if (!type) return + + try { + if (type === 'REQUEST_TPEN_ID_TOKEN') { + this.#sendIdTokenToTool(event.source) + return } + + if (type === 'REQUEST_POPULATED_PROJECT') { + this.#postToTool(this.#buildPopulatedProject(), event.source) + return + } + + if (type === 'REQUEST_POPULATED_PAGE') { + this.#postToTool(await this.#buildPopulatedPage(), event.source) + return + } + + if (type === 'NAVIGATE_TO_LINE') { + const lineId = event.data?.lineId + if (!lineId) return + const lineIndex = this.#page?.items?.findIndex(item => { + const itemId = item.id ?? item['@id'] + return itemId === lineId || itemId?.endsWith?.(`/${lineId}`) || itemId?.split?.('/').pop() === lineId + }) + if (lineIndex !== undefined && lineIndex !== -1) { + TPEN.activeLineIndex = lineIndex + this.updateLines() + } + } + } catch (err) { + console.error(`[simple-transcription] tool message handler threw on type=${type}`, err) } } } diff --git a/components/transcription-block/index.js b/components/transcription-block/index.js index 8e8e4cc8..4a420ce5 100644 --- a/components/transcription-block/index.js +++ b/components/transcription-block/index.js @@ -155,16 +155,11 @@ export default class TranscriptionBlock extends HTMLElement { if (typeof index === 'number') this.scheduleLineSave(index) }) - // Window message handler for external tool communication + // Window message handler for external tool communication. Line + // navigation is owned by simple-transcription; this listener only + // handles UPDATE_LINE_TEXT (e.g. Line-Breaking, Preview-Transcription + // pushing edited line text into the active transcription block). this.renderCleanup.onWindow('message', (event) => { - if (event.data?.type === "RETURN_LINE_ID") { - const lineIndex = this.#page.items.findIndex(item => item.id === event.data.lineId) - if (lineIndex !== -1) { - this.moveToLine(lineIndex, 'next') - this.updateTranscriptionUI() - } - } - if (event.data?.type === "UPDATE_LINE_TEXT") { if (typeof event.data.lineIndex === 'number') { this.shadowRoot.querySelector('.transcription-input').value = event.data.text