Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 117 additions & 65 deletions components/simple-transcription/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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()

Expand Down Expand Up @@ -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)
Expand All @@ -1143,39 +1189,45 @@ export default class SimpleTranscriptionInterface extends HTMLElement {
this.checkMagnifierVisibility?.()
}

#handleToolMessages(event) {
async #handleToolMessages(event) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compare to canvas-panel handling which also checks these events.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transcription-canvas-panel/index.js is orphaned — flagged out of scope in the plan and not instantiated anywhere. Left alone here; eventual removal is its own piece of work.

// 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)
}
}
}
Expand Down
13 changes: 4 additions & 9 deletions components/transcription-block/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be replaced with NAVIGATE? It seems to fit here better than the simple-transcription, above. Probably even more correct would be to watch TPEN.screen.activeLine or whatevs and just change that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good architectural question — agreed it's worth it. Filing as a follow-up: this cut intentionally centralizes line-nav ownership in simple-transcription so the protocol has a single owner. Refactoring to a global TPEN.screen.activeLine subscription is a separate piece of work.

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
Expand Down
Loading