diff --git a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts index ec61b9db8..03ef62715 100644 --- a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts +++ b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/Extension.ts @@ -9,7 +9,6 @@ import DownloadDialogue from "./DownloadDialogue"; import { OpenSeadragonExtensionEvents } from "./Events"; import { ExternalContentDialogue } from "../../modules/uv-dialogues-module/ExternalContentDialogue"; import { FooterPanel as MobileFooterPanel } from "../../modules/uv-osdmobilefooterpanel-module/MobileFooter"; -// import { FooterPanel } from "../../modules/uv-searchfooterpanel-module/FooterPanel"; import { FooterPanel } from "../../modules/uv-shared-module/FooterPanel"; import { HelpDialogue } from "../../modules/uv-dialogues-module/HelpDialogue"; import { IOpenSeadragonExtensionData } from "./IOpenSeadragonExtensionData"; @@ -894,6 +893,91 @@ export default class OpenSeadragonExtension extends BaseExtension { return groupedAnnotations; } + groupWebAnnotationResultsByTarget(searchResults: any): AnnotationGroup[] { + const groupedAnnotations: AnnotationGroup[] = []; + + //we need to sort the items by canvas and position first, so that they appear in reading order + const sortedItems = [...searchResults.items].sort((a, b) => { + const canvasIdA = a.target.id.match(/(.*)#/)?.[1]; + const canvasIdB = b.target.id.match(/(.*)#/)?.[1]; + + if (!canvasIdA || !canvasIdB) return 0; + + const canvasIndexA = this.helper.getCanvasIndexById(canvasIdA); + const canvasIndexB = this.helper.getCanvasIndexById(canvasIdB); + + if (canvasIndexA === null || canvasIndexB === null) return 0; + + // First sort by canvas index + const canvasDiff = canvasIndexA - canvasIndexB; + if (canvasDiff !== 0) return canvasDiff; + + // Then sort by position within canvas + const boundsMatchA = a.target.id.match(/#(xywh=.+)$/); + const boundsMatchB = b.target.id.match(/#(xywh=.+)$/); + + if (boundsMatchA && boundsMatchB) { + try { + const boundsA = XYWHFragment.fromString(boundsMatchA[1]); + const boundsB = XYWHFragment.fromString(boundsMatchB[1]); + + const yDiff = boundsA.y - boundsB.y; + if (yDiff !== 0) return yDiff; + return boundsA.x - boundsB.x; + } catch (error) { + console.warn("Failed to parse bounds for sorting:", error); + } + } + + return 0; + }); + + for (const item of sortedItems) { + // Extract canvas ID from the target.id (everything before the #) + const canvasId = item.target.id.match(/(.*)#/)?.[1]; + if (!canvasId) continue; + + // Get canvas index, skip if null + const canvasIndex = this.helper.getCanvasIndexById(canvasId); + if (canvasIndex === null) continue; + + // Check if we already have an annotation group for this canvas + const existingGroup = groupedAnnotations.find( + (group) => group.canvasId === canvasId + ); + + // Transform W3C annotation to match AnnotationRect constructor expectations + // This is to get around Manifold's current way of handling w3c annos, which doesn't fit with content search 2 results + // but as it may be used elsewhere, don't want to change Manifold till looking at it properly + // related to this is the pre-existing groupWebAnnotationsByTarget function. groupWebAnnotationResultsByTarget could potentially + // replace that function but need to check it wouldn't break anything. + const transformedItem = { + target: item.target.id, // Convert object.id to string + bodyValue: item.body?.value || "", // Convert body.value to bodyValue + }; + + if (existingGroup) { + // Add rect to existing group + existingGroup.addRect(transformedItem); + } else { + // Create new annotation group + const annotationGroup = new AnnotationGroup(canvasId); + annotationGroup.canvasIndex = canvasIndex; + annotationGroup.addRect(transformedItem); + groupedAnnotations.push(annotationGroup); + } + } + + // Sort by canvas index + groupedAnnotations.sort((a, b) => { + return a.canvasIndex - b.canvasIndex; + }); + + console.log(groupedAnnotations); + + return groupedAnnotations; + } + groupSearchHitsByTarget(searchHits: any): SearchHit[] { const groupedSearchHits: SearchHit[] = []; let currentIndex = 0; @@ -945,6 +1029,142 @@ export default class OpenSeadragonExtension extends BaseExtension { return groupedSearchHits; } + // this function uses xwyh to sort by position on canvas too, so results are in reading order (if language is top to bottom, left to right!!), so this should also be applied to groupSearchHitsByTarget + sortWebAnnotationsSearchHits(searchResults: any): SearchHit[] { + const groupedSearchHits: SearchHit[] = []; + let currentIndex = 0; + let oldCanvasIndex: number | null = null; + + // Create a map of source annotation ID to canvas info for quick lookup + const sourceAnnotationMap = new Map< + string, + { canvasId: string; canvasIndex: number; bounds: XYWHFragment } + >(); + + // Process the main items to build the lookup map + for (const item of searchResults.items) { + const canvasId = item.target.id.match(/(.*)#/)?.[1]; + const boundsMatch = item.target.id.match(/#(xywh=.+)$/); + + if (canvasId && boundsMatch) { + const canvasIndex = this.helper.getCanvasIndexById(canvasId); + if (canvasIndex !== null) { + try { + const bounds = XYWHFragment.fromString(boundsMatch[1]); + sourceAnnotationMap.set(item.id, { + canvasId, + canvasIndex, + bounds, + }); + } catch (error) { + // Skip items with invalid bounds format + console.warn( + `Invalid bounds format for annotation ${item.id}:`, + boundsMatch[1] + ); + } + } + } + } + + // Process highlighting annotations if they exist + if (searchResults.annotations && searchResults.annotations.length > 0) { + const highlightingAnnotations = searchResults.annotations[0].items || []; + + // Sort highlighting annotations by canvas index, then coordinates to ensure correct order + highlightingAnnotations.sort((a, b) => { + const sourceA = sourceAnnotationMap.get(a.target.source); + const sourceB = sourceAnnotationMap.get(b.target.source); + if (!sourceA || !sourceB) return 0; + + // sort by canvas index + const canvasDiff = sourceA.canvasIndex - sourceB.canvasIndex; + if (canvasDiff !== 0) return canvasDiff; + + // sort by spatial position within canvas (top to bottom, left to right) + const yDiff = sourceA.bounds.y - sourceB.bounds.y; + if (yDiff !== 0) return yDiff; + return sourceA.bounds.x - sourceB.bounds.x; + }); + + for (const highlightAnnotation of highlightingAnnotations) { + const sourceInfo = sourceAnnotationMap.get( + highlightAnnotation.target.source + ); + + if (!sourceInfo) continue; + + const { canvasId, canvasIndex } = sourceInfo; + + // Handle canvas index tracking for grouping + if (canvasIndex !== oldCanvasIndex) { + currentIndex = 0; + oldCanvasIndex = canvasIndex; + } else { + currentIndex++; + } + + // Extract match details from the TextQuoteSelector + const selector = highlightAnnotation.target.selector?.[0]; + if (selector && selector.type === "TextQuoteSelector") { + const searchHit = new SearchHit(); + searchHit.canvasId = canvasId; + searchHit.canvasIndex = canvasIndex; + searchHit.before = selector.prefix || ""; + searchHit.after = selector.suffix || ""; + searchHit.match = selector.exact || ""; + searchHit.index = currentIndex; + + groupedSearchHits.push(searchHit); + } + } + } else { + // if no highlighting annotations, process main items directly + // handles cases where the search service doesn't provide separate highlighting annotations + const sortedItems = [...searchResults.items].sort((a, b) => { + const canvasIdA = a.target.id.match(/(.*)#/)?.[1]; + const canvasIdB = b.target.id.match(/(.*)#/)?.[1]; + if (!canvasIdA || !canvasIdB) return 0; + + const indexA = this.helper.getCanvasIndexById(canvasIdA); + const indexB = this.helper.getCanvasIndexById(canvasIdB); + + // Handle null values - treat null as -1 to sort them to the beginning + const safeIndexA = indexA ?? -1; + const safeIndexB = indexB ?? -1; + + return safeIndexA - safeIndexB; + }); + + for (const item of sortedItems) { + const canvasId = item.target.id.match(/(.*)#/)?.[1]; + if (!canvasId) continue; + + const canvasIndex = this.helper.getCanvasIndexById(canvasId); + if (canvasIndex === null) continue; // Skip items with invalid canvas indices + + if (canvasIndex !== oldCanvasIndex) { + currentIndex = 0; + oldCanvasIndex = canvasIndex; + } else { + currentIndex++; + } + + const searchHit = new SearchHit(); + searchHit.canvasId = canvasId; + searchHit.canvasIndex = canvasIndex; + searchHit.before = ""; + searchHit.after = ""; + searchHit.match = item.body?.value || ""; + searchHit.index = currentIndex; + + groupedSearchHits.push(searchHit); + } + } + + return groupedSearchHits; + } + checkForSearchParam(): void { // if a highlight param is set, use it to search. const highlight: string | undefined = (( @@ -1513,7 +1733,8 @@ export default class OpenSeadragonExtension extends BaseExtension { if (!service) return null; return ( service.getService(ServiceProfile.SEARCH_0_AUTO_COMPLETE) || - service.getService(ServiceProfile.SEARCH_1_AUTO_COMPLETE) + service.getService(ServiceProfile.SEARCH_1_AUTO_COMPLETE) || + service.getService(ServiceProfile.SEARCH_2_AUTO_COMPLETE) ); } @@ -1584,12 +1805,23 @@ export default class OpenSeadragonExtension extends BaseExtension { .then((response) => response.json()) .then((results) => { if (results.resources && results.resources.length) { + // content search api 1 searchResults = searchResults.concat( this.groupOpenAnnotationsByTarget(results) ); searchHits = searchHits.concat(this.groupSearchHitsByTarget(results)); + } else if (results.items && results.items.length) { + // content search api 2 + searchResults = searchResults.concat( + this.groupWebAnnotationResultsByTarget(results) + ); + searchHits = searchHits.concat( + this.sortWebAnnotationsSearchHits(results) + ); } + // it's here looping through all of the search pages in one request, which could be a big load + // It would be better to properly use pagination here if available if (results.next) { this.getSearchResults( results.next, diff --git a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/config/Config.ts b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/config/Config.ts index 94b60b90e..04925c14a 100644 --- a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/config/Config.ts +++ b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/config/Config.ts @@ -176,6 +176,8 @@ type SearchLeftPanelOptions = DialogueOptions & textLimit: number; /** Type of the text limit */ textLimitType: string; + autocompleteAllowWords: Boolean; + autoCompleteBoxEnabled: Boolean; }; type SearchLeftPanelContent = DialogueContent & diff --git a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/config/config.json b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/config/config.json index da354569c..afb612786 100644 --- a/src/content-handlers/iiif/extensions/uv-openseadragon-extension/config/config.json +++ b/src/content-handlers/iiif/extensions/uv-openseadragon-extension/config/config.json @@ -78,7 +78,9 @@ "showAllLanguages": false, "textLimit": 4, "textLimitType": "lines", - "topCloseButtonEnabled": false + "topCloseButtonEnabled": false, + "autocompleteAllowWords": true, + "autoCompleteBoxEnabled": true }, "content": { "attribution": "$attribution", @@ -479,6 +481,7 @@ "searchFooterPanel": { "options": { "autocompleteAllowWords": false, + "autoCompleteBoxEnabled": true, "elideDetailsTermsCount": 20, "elideResultsTermsCount": 10, "forceImageMode": false, diff --git a/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts b/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts index fc7e18376..3cba1fcf9 100644 --- a/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts +++ b/src/content-handlers/iiif/modules/uv-searchleftpanel-module/SearchLeftPanel.ts @@ -8,11 +8,11 @@ import OpenSeadragonExtension from "../../extensions/uv-openseadragon-extension/ import { AnnotationRect } from "@iiif/manifold"; import { AnnotationResults } from "../uv-shared-module/AnnotationResults"; import { SearchHit } from "../uv-shared-module/SearchHit"; -import { Keyboard, Strings } from "../../Utils"; -import * as KeyCodes from "../../KeyCodes"; +import { Keyboard, Strings, Bools } from "../../Utils"; +import * as KeyCodes from "@edsilv/key-codes"; import { URLAdapter } from "../../URLAdapter"; import { XYWHFragment } from "../uv-shared-module/XYWHFragment"; - +import { AutoComplete } from "../uv-shared-module/AutoComplete"; export class SearchLeftPanel extends LeftPanel { $searchButton: JQuery; $searchContainer: JQuery; @@ -370,6 +370,44 @@ export class SearchLeftPanel extends LeftPanel { )).centerPanel.preserveViewportForQuery = false; } }, 100); // unfortunately this is needed :-( + + // add autocomplete + var that = this; + + const autocompleteService: string | null = (( + this.extension + )).getAutoCompleteUri(); + + if (autocompleteService) { + new AutoComplete( + this.$searchText, + (terms: string, cb: (results: string[]) => void) => { + fetch(Strings.format(autocompleteService, terms)) + .then((response) => response.json()) + .then((results) => { + cb(results); + }); + }, + (results: any) => { + return $.map(results.terms, (result: any) => { + return result.match; + }); + }, + (terms: string) => { + this.search(terms); + }, + 300, + 2, + false, + Bools.getBool(that.config.options.autocompleteAllowWords, true) + ); + } else { + this.$searchText.on("keyup", (e) => { + if (e.keyCode === KeyCodes.KeyDown.Enter) { + that.search(that.$searchText.val()); + } + }); + } } search(terms: string): void { diff --git a/src/content-handlers/iiif/modules/uv-searchleftpanel-module/css/styles.less b/src/content-handlers/iiif/modules/uv-searchleftpanel-module/css/styles.less index 949a4fb17..a566f224c 100644 --- a/src/content-handlers/iiif/modules/uv-searchleftpanel-module/css/styles.less +++ b/src/content-handlers/iiif/modules/uv-searchleftpanel-module/css/styles.less @@ -148,8 +148,11 @@ align-items: flex-end; .autocomplete { + font-size: 12px; + z-index: 9999; + background-color: white; position: absolute; - width: 270px; + width: 100%; border: 2px solid @brand-primary-lighter; list-style-type: none; -webkit-margin-before: 0; @@ -167,6 +170,10 @@ width: 270px; overflow: hidden; + a { + color: black; + } + &.loading { background-image: data-uri(@loader-white-bg); background-repeat: no-repeat; diff --git a/src/content-handlers/iiif/modules/uv-shared-module/AutoComplete.ts b/src/content-handlers/iiif/modules/uv-shared-module/AutoComplete.ts index b36019a4a..abfa32985 100644 --- a/src/content-handlers/iiif/modules/uv-shared-module/AutoComplete.ts +++ b/src/content-handlers/iiif/modules/uv-shared-module/AutoComplete.ts @@ -220,6 +220,7 @@ export class AutoComplete { } private _hideResults(): void { + console.trace("_hideResults called"); // Changed from console.log to console.trace this._$searchResultsList.hide(); } diff --git a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts index 004d525f2..5620658d0 100644 --- a/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts +++ b/src/content-handlers/iiif/modules/uv-textrightpanel-module/TextRightPanel.ts @@ -11,6 +11,14 @@ import { Shell } from "../uv-shared-module/Shell"; import { AnnotationRect } from "@iiif/manifold"; import { OpenSeadragonExtensionEvents } from "../../extensions/uv-openseadragon-extension/Events"; +import { AnnotationPage, Annotation, IManifestoOptions } from "manifesto.js"; +interface LineData { + text: string; + x: number; + y: number; + width: number; + height: number; +} export class TextRightPanel extends RightPanel { $transcribedText: JQuery; $spinner: JQuery; @@ -110,80 +118,13 @@ export class TextRightPanel extends RightPanel { return (intersectionArea / rect1Area) * 100; } - function highlightSearchHit( - element: Element, - searchText: string, - index: string | number, - canvasIndex: string | number - ): void { - // traverse only text nodes - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, { - acceptNode: function (node: Node): number { - // skip text nodes that are already inside searchHitSpan elements - const parent = node.parentElement; - if (parent && parent.classList.contains("searchHitSpan")) { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - }, - }); - - let currentNode: Node | null; - const textNodes: Text[] = []; - - // collect all valid text nodes - while ((currentNode = walker.nextNode())) { - textNodes.push(currentNode as Text); - } - - // find the first occurrence of search hit - for (const textNode of textNodes) { - const textContent = textNode.textContent || ""; - const hitIndex = textContent.indexOf(searchText); - - if (hitIndex !== -1) { - // split the text node and wrap the match - const beforeText = textContent.substring(0, hitIndex); - const matchText = textContent.substring( - hitIndex, - hitIndex + searchText.length - ); - const afterText = textContent.substring(hitIndex + searchText.length); - - // create highlight span - const highlightSpan = document.createElement("span"); - highlightSpan.className = "searchHitSpan"; - highlightSpan.setAttribute("data-index", String(index)); - highlightSpan.setAttribute("data-canvas-index", String(canvasIndex)); - highlightSpan.textContent = matchText; - - const parent = textNode.parentNode; - - if (parent) { - // replace original text node with the parts - if (beforeText) { - const beforeNode = document.createTextNode(beforeText); - parent.insertBefore(beforeNode, textNode); - } - - parent.insertBefore(highlightSpan, textNode); - - if (afterText) { - const afterNode = document.createTextNode(afterText); - parent.insertBefore(afterNode, textNode); - } - - // remove the original text node - parent.removeChild(textNode); - } + this.extensionHost.on(Events.SEARCH_HIT_CHANGED, (e) => { + // this reacts to a new search hit being selected and styles the elements (rect on the canvas, span in the full text) appropriately - // stop after finding and wrapping the first occurrence - break; - } - } - } + // the e object has hitIndex, rectIndex, and canvasIndex for the search result that was just selected. + // rectIndex is the index of the rect on the canvas. hit index is the result index + // console.log(e); - this.extensionHost.on(Events.SEARCH_HIT_CHANGED, (e) => { this.currentRectIndex = e[0].rectIndex; const canvasIndex = this.extension.helper.canvasIndex; this.currentHitIndex = e[0].hitIndex; @@ -231,6 +172,7 @@ export class TextRightPanel extends RightPanel { this.extensionHost.on( OpenSeadragonExtensionEvents.CANVAS_CLICK, (e: any) => { + console.log(e.originalTarget); var target = e.originalTarget || e.originalEvent.target; $(target).trigger("click"); } @@ -260,7 +202,11 @@ export class TextRightPanel extends RightPanel { this.removeLineAnnotationRects(); for (let i = 0; i < canvases.length; i++) { const c = canvases[i]; + const seeAlso = c.getProperty("seeAlso"); + + const annotations = c.getAnnotations(); + let header; if (i === 0 && canvases.length > 1) { @@ -290,14 +236,15 @@ export class TextRightPanel extends RightPanel { this.$transcribedText.html(""); } - // We need to see if seeAlso contains an ALTO file and maybe allow for other HTR/OCR formats in the future - // and make sure which version of IIIF Presentation API is used - if (seeAlso.length === undefined) { + if (annotations.length) { + // Check for annotations on the canvas first + await this.processWebAnnotations(annotations, c.index, header); + } else if (seeAlso && seeAlso.length === undefined) { // This is IIIF Presentation API < 3 if (seeAlso.profile.includes("alto")) { await this.processAltoFile(seeAlso["@id"], c.index, header); } - } else { + } else if (seeAlso && seeAlso.length > 0) { // This is IIIF Presentation API >= 3 if (seeAlso[0].profile.includes("alto")) { await this.processAltoFile(seeAlso[0]["id"], c.index, header); @@ -309,6 +256,7 @@ export class TextRightPanel extends RightPanel { .filter((rect) => { return rect["canvasIndex"] == c.index; }); + annotationRects.forEach((annotationRect) => { const rect = { x: annotationRect.x, @@ -316,6 +264,7 @@ export class TextRightPanel extends RightPanel { width: annotationRect.width, height: annotationRect.height, }; + $("div.lineAnnotationRect").each( (i: Number, lineAnnotationRect: any) => { const x = $(lineAnnotationRect).data("x"); @@ -330,7 +279,7 @@ export class TextRightPanel extends RightPanel { "div#" + $(lineAnnotationRect).attr("id") + ".lineAnnotation" ); if (lineElement[0]) { - highlightSearchHit( + this.highlightSearchHit( lineElement[0], annotationRect.chars, annotationRect.index, @@ -384,6 +333,79 @@ export class TextRightPanel extends RightPanel { this.$top.parent().addClass("textRightPanel"); } + highlightSearchHit( + element: Element, + searchText: string, + index: string | number, + canvasIndex: string | number + ): void { + // traverse only text nodes + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, { + acceptNode: function (node: Node): number { + // skip text nodes that are already inside searchHitSpan elements + const parent = node.parentElement; + if (parent && parent.classList.contains("searchHitSpan")) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + + let currentNode: Node | null; + const textNodes: Text[] = []; + + // collect all valid text nodes + while ((currentNode = walker.nextNode())) { + textNodes.push(currentNode as Text); + } + + // find the first occurrence of search hit + for (const textNode of textNodes) { + const textContent = textNode.textContent || ""; + const hitIndex = textContent.indexOf(searchText); + + if (hitIndex !== -1) { + // split the text node and wrap the match + const beforeText = textContent.substring(0, hitIndex); + const matchText = textContent.substring( + hitIndex, + hitIndex + searchText.length + ); + const afterText = textContent.substring(hitIndex + searchText.length); + + // create highlight span + const highlightSpan = document.createElement("span"); + highlightSpan.className = "searchHitSpan"; + highlightSpan.setAttribute("data-index", String(index)); + highlightSpan.setAttribute("data-canvas-index", String(canvasIndex)); + highlightSpan.textContent = matchText; + + const parent = textNode.parentNode; + + if (parent) { + // replace original text node with the parts + if (beforeText) { + const beforeNode = document.createTextNode(beforeText); + parent.insertBefore(beforeNode, textNode); + } + + parent.insertBefore(highlightSpan, textNode); + + if (afterText) { + const afterNode = document.createTextNode(afterText); + parent.insertBefore(afterNode, textNode); + } + + // remove the original text node + parent.removeChild(textNode); + } + + // stop after finding and wrapping the first occurrence + break; + } + } + } + toggleFinish(): void { super.toggleFinish(); } @@ -394,16 +416,168 @@ export class TextRightPanel extends RightPanel { this.$main.height( this.$element.height() - this.$top.height() - this.$main.verticalMargins() ); + } + + private extractAltoData(altoDoc: Document): LineData[] { + const textLines = altoDoc.querySelectorAll("TextLine"); + + return Array.from(textLines).map((e) => { + const strings = e.querySelectorAll("String"); + const t = Array.from(strings).map((s) => s.getAttribute("CONTENT")); + const text = t.join(" "); + + let x = Number(e.getAttribute("HPOS")); + const y = Number(e.getAttribute("VPOS")); + const width = Number(e.getAttribute("WIDTH")); + const height = Number(e.getAttribute("HEIGHT")); + + x = + x + + this.offsetX + + (this.index > 0 ? this.centerPanel.config.options.pageGap : 0); + + this.clipboardText += text + " "; + + return { text, x, y, width, height }; + }); + } + + private extractWebAnnotationData(annotations: Annotation[]): LineData[] { + console.log("processing ", annotations); + return annotations + .map((a) => { + const bodies = a.getBody(); + if (!bodies || bodies.length === 0) return null; + + const body = bodies[0]; + const text = body.getValue(); + const target = a?.getTarget(); + + if (!target) return null; + + const xywh = target.split("#xywh=")[1]; + + let baseX: number, y: number, width: number, height: number; + + if (!xywh) { + // Target is the whole canvas - give 0 dimension. + baseX = 0; + y = 0; + width = 0; + height = 0; + } else { + [baseX, y, width, height] = xywh.split(",").map(Number); + } - /* this.$element.css({ - left: Math.floor( - this.$element.parent().width() - this.$element.outerWidth() - this.options.panelCollapsedWidth - ), - }); */ + const x = + baseX + + this.offsetX + + (this.index > 0 ? this.centerPanel.config.options.pageGap : 0); + + this.clipboardText += text + " "; + + return { text, x, y, width, height }; + }) + .filter((line): line is LineData => line !== null); } - // Let's load the ALTO file and do some parsing - processAltoFile = async (altoUrl, canvasIndex, header?): Promise => { + private createLineElements( + lineDataArray: LineData[], + canvasIndex: number + ): JQuery[] { + return lineDataArray.map((lineData, i) => { + const { text, x, y, width, height } = lineData; + + const line = $( + `
` + ).text(text); + + if (!this.extension.isMobile()) { + // Create overlay rectangle + const div = $( + `
` + ).attr("title", text); + + this.attachLineEventHandlers(div, line); + + // Add overlay to OpenSeadragon canvas + const osRect = new OpenSeadragon.Rect(x, y, width, height); + (this.extension).centerPanel.viewer.addOverlay( + div[0], + osRect + ); + } + + return line; + }); + } + + private attachLineEventHandlers(div: JQuery, line: JQuery): void { + const handleClick = (target: HTMLElement) => { + const canvasIndex = Number(target.getAttribute("id")!.split("-")[2]); + if (canvasIndex !== this.currentCanvasIndex) { + this.extension.helper.canvasIndex = canvasIndex; + this.currentCanvasIndex = canvasIndex; + } + + this.clearLineAnnotationRects(); + this.clearLineAnnotations(); + this.setCurrentLineAnnotation(target, true); + this.setCurrentLineAnnotationRect(target); + }; + + // Div (overlay) handlers + div.on("keydown", (e: any) => { + if (e.keyCode === 13) $(e.target).trigger("click"); + }); + div.on("click", (e: any) => handleClick(e.target)); + + // Line (text) handlers + line.on("keydown", (e: any) => { + if (e.keyCode === 13) $(e.target).trigger("click"); + }); + line.on("click", (e: any) => handleClick(e.currentTarget)); + } + + private renderTranscribedText(lines: JQuery[], header?: string): void { + if (!this.$transcribedText) { + this.$transcribedText = $('
'); + } + + if (header) { + this.$transcribedText.append($(`
${header}
`)); + } + + if (lines.length > 0) { + this.$transcribedText.append(lines); + this.$transcribedText.attr("data-text", this.clipboardText.trimEnd()); + } else { + this.$transcribedText.append( + $(`
${this.content.textNotFound}
`) + ); + } + + if ( + this.$transcribedText[0]?.firstElementChild?.firstChild?.toString().trim() + ) { + this.$spinner.hide(); + } + + this.$main.append(this.$transcribedText); + + // Restore previously selected annotation + if (this.$existingAnnotation[0] !== undefined) { + const id = $(this.$existingAnnotation).attr("id"); + if ($("div#" + id).length > 0) { + this.setCurrentLineAnnotation($("div#" + id)[0], true); + this.setCurrentLineAnnotationRect($("div#" + id)[0]); + } + } + } + + private showSpinner(): void { this.$spinner = $('
'); this.$spinner.css( "top", @@ -411,142 +585,78 @@ export class TextRightPanel extends RightPanel { ); this.$main.append(this.$spinner); this.$spinner.show(); + } + + processAltoFile = async ( + altoUrl: string, + canvasIndex: number, + header?: string + ): Promise => { + this.showSpinner(); + try { const response = await fetch(altoUrl); const data = await response.text(); const altoDoc = new DOMParser().parseFromString(data, "application/xml"); - const textLines = altoDoc.querySelectorAll("TextLine"); - const lines = Array.from(textLines).map((e, i) => { - const strings = e.querySelectorAll("String"); - const t = Array.from(strings).map((e, i) => { - return e.getAttribute("CONTENT"); - }); - let x = Number(e.getAttribute("HPOS")); - const y = Number(e.getAttribute("VPOS")); - const width = Number(e.getAttribute("WIDTH")); - const height = Number(e.getAttribute("HEIGHT")); - x = - x + - this.offsetX + - (this.index > 0 ? this.centerPanel.config.options.pageGap : 0); - const text = t.join(" "); - this.clipboardText += text + " "; + const lineDataArray = this.extractAltoData(altoDoc); + const lines = this.createLineElements(lineDataArray, canvasIndex); - const line = $( - '
' + - text + - "
" - ); + this.renderTranscribedText(lines, header); + } catch (error) { + console.error("Unable to fetch Alto file:", error); + this.$spinner.hide(); + this.$transcribedText = $('
'); + this.$transcribedText.append( + $(`
${this.content.textNotFound}
`) + ); + this.$main.append(this.$transcribedText); + } + }; - if (!this.extension.isMobile()) { - const div = $( - '
' - ); - $(div).on("keydown", (e: any) => { - if (e.keyCode === 13) { - $(e.target).trigger("click"); - } - }); - $(div).on("click", (e: any) => { - const canvasIndex = Number( - e.target.getAttribute("id").split("-")[2] - ); - // We change the current canvas index to the clicked page (if we're in two page view) - if (canvasIndex !== this.currentCanvasIndex) { - this.extension.helper.canvasIndex = canvasIndex; - this.currentCanvasIndex = canvasIndex; - } - this.clearLineAnnotationRects(); - this.clearLineAnnotations(); - this.setCurrentLineAnnotation(e.target, true); - this.setCurrentLineAnnotationRect(e.target); - }); - // Add overlay to OpenSeadragon canvas - const osRect = new OpenSeadragon.Rect(x, y, width, height); - (( - this.extension - )).centerPanel.viewer.addOverlay(div[0], osRect); - - line.on("keydown", (e: any) => { - if (e.keyCode === 13) { - $(e.target).trigger("click"); - } - }); - // Sync line click with line annotation - line.on("click", (e: any) => { - const target = e.currentTarget; - const canvasIndex = Number(target.getAttribute("id").split("-")[2]); - // We change the current canvas index to the clicked page (if we're in two page view) - if (canvasIndex !== this.currentCanvasIndex) { - this.extension.helper.canvasIndex = canvasIndex; - this.currentCanvasIndex = canvasIndex; - } - this.clearLineAnnotationRects(); - this.clearLineAnnotations(); - this.setCurrentLineAnnotation(target, false); - this.setCurrentLineAnnotationRect(target); - }); - } - return line; - }); + processWebAnnotations = async ( + annotations: AnnotationPage[], + canvasIndex: number, + header?: string + ): Promise => { + this.showSpinner(); - if (!this.$transcribedText) { - this.$transcribedText = $('
'); - } - if (header) { - this.$transcribedText.append( - $('
' + header + "
") - ); - } - if (lines.length > 0) { - this.$transcribedText.append(lines); - this.$transcribedText.attr("data-text", this.clipboardText.trimEnd()); - } else { - this.$transcribedText.append( - $("
" + this.content.textNotFound + "
") - ); - } + try { + for (const annotationPageRef of annotations) { + let annotationPage: AnnotationPage; + + // Check if annotations are embedded or referenced + const embeddedAnnotations = annotationPageRef.getAnnotations(); + + if (embeddedAnnotations && embeddedAnnotations.length > 0) { + // Annotations are embedded + annotationPage = annotationPageRef; + } else if (annotationPageRef.id) { + // Annotations are referenced + const response = await fetch(annotationPageRef.id); + const annotationPageData = await response.json(); + + const options: IManifestoOptions = { + locale: this.extension.helper.options.locale ?? "en-GB", + }; - if ( - this.$transcribedText[0]?.firstElementChild?.firstChild - ?.toString() - .trim() - ) { - this.$spinner.hide(); - } + annotationPage = new AnnotationPage(annotationPageData, options); + } else { + // No annotations + continue; + } - this.$main.append(this.$transcribedText); + const annotationsList = annotationPage.getAnnotations(); + + if (annotationsList && annotationsList.length > 0) { + const lineDataArray = this.extractWebAnnotationData(annotationsList); + const lines = this.createLineElements(lineDataArray, canvasIndex); - // If we already have a selected line annotation, make sure it's selected again after load - if (this.$existingAnnotation[0] !== undefined) { - const id = $(this.$existingAnnotation).attr("id"); - if ($("div#" + id).length > 0) { - // Make sure the line annotation exists in the DOM - this.setCurrentLineAnnotation($("div#" + id)[0], true); - this.setCurrentLineAnnotationRect($("div#" + id)[0]); + this.renderTranscribedText(lines, header); } } } catch (error) { - throw new Error("Unable to fetch Alto file: " + error.message); + console.error("Error processing annotations:", error); } }; @@ -607,6 +717,7 @@ export class TextRightPanel extends RightPanel { $("div.lineAnnotationRect").remove(); } + //this styles the annotation on the OSD canvas. BUT it doesn't bring that rect into view if it's currently off screen. do we want that? setCurrentAnnotation(canvasIndex: any, index: any): void { $(".annotationRect").each((i: number, annotation: any) => { if ($(annotation).hasClass("current")) { diff --git a/src/iiif-collection.json b/src/iiif-collection.json index 0b89926e5..af1c67428 100644 --- a/src/iiif-collection.json +++ b/src/iiif-collection.json @@ -1179,6 +1179,36 @@ "@type": "sc:Manifest", "label": "Swedish National Archives", "visible": true + }, + { + "@id": "https://miiify-a58u.onrender.com/manifest/0001", + "@type": "sc:Manifest", + "label": "BL Annotations Test", + "visible": true + }, + { + "@id": "https://iiif.io/api/cookbook/recipe/0266-full-canvas-annotation/manifest.json", + "@type": "sc:Manifest", + "label": "IIIF Cookbook: Simplest Annotation (embedded)", + "visible": true + }, + { + "@id": "https://iiif.io/api/cookbook/recipe/0269-embedded-or-referenced-annotations/manifest.json", + "@type": "sc:Manifest", + "label": "IIIF Cookbook: Referenced Annotation", + "visible": true + }, + { + "@id": "https://iiif.wellcomecollection.org/presentation/b19974760_133_0018", + "@type": "sc:Manifest", + "label": "Chemist & Druggist", + "visible": true + }, + { + "@id": "https://digital.lib.utk.edu/assemble/manifest/insurancena/125", + "@type": "sc:Manifest", + "label": "Letter, Elliston Perot & John Perot (full canvas annos)", + "visible": true } ] }