diff --git a/src/plugin/Panel/MediaDialog/index.tsx b/src/plugin/Panel/MediaDialog/index.tsx index 85deb77..70b2295 100644 --- a/src/plugin/Panel/MediaDialog/index.tsx +++ b/src/plugin/Panel/MediaDialog/index.tsx @@ -1,8 +1,9 @@ import { Dialog, Heading, ImageSelect } from "@components"; import { usePlugin } from "@context"; -import type { CanvasNormalized, ContentResourceNormalized } from "@iiif/presentation-3-normalized"; +import { serializeConfigPresentation3, Traverse } from "@iiif/parser"; +import type { Canvas, ContentResource } from "@iiif/presentation-3"; import type { Media } from "@types"; -import { getLabelByUserLanguage } from "@utils"; +import { getLabelByUserLanguage, updateIIIFImageRequestURI } from "@utils"; import { FC, useEffect, useRef } from "react"; import style from "./style.module.css"; @@ -23,6 +24,37 @@ function handleSelectedMedia( onUpdate(resources); } +// Though the resource may have `width` and `height` properties +// it's type does not include them. +// To ensure we can access them safely, without excessive type checking, +// cast the resource to `any` and then try to access them. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getDimensions(resource: any): { height: number; width: number } { + return { + height: resource?.height || 0, + width: resource?.width || 0, + }; +} + +/** + * Format a caption string for a IIIF resource including its dimensions if available. + * + * @param topline the top line of the caption, e.g. "Thumbnail" + * @param resource the IIIF resource to get dimensions from + * @returns a formatted caption string + * + * @example + * ```ts + * formatCaption("Thumbnail", resource); + * // "Thumbnail\n(100 x 200)" + * ``` + */ +function formatCaption(topline: string, resource: ContentResource) { + const { width, height } = getDimensions(resource); + const dimensions = width && height ? `\n(${width} x ${height})` : ""; + return topline + dimensions; +} + const CurrentView = () => { const { state, dispatch } = usePlugin(); @@ -88,89 +120,132 @@ const CurrentView = () => { ); }; -const Placeholder = () => { +const Paintings: FC<{ canvas: Canvas }> = ({ canvas }) => { const { state, dispatch } = usePlugin(); function handleAddMedia(selected: boolean, media: Media) { + // when adding the media, update the size to be max + media.src = updateIIIFImageRequestURI(media.src, { + size: `max`, + }); handleSelectedMedia(selected, media, state.selectedMedia, (resources) => dispatch({ type: "setSelectedMedia", selectedMedia: resources }), ); } - // Though the resource may have `width` and `height` properties - // it's type, ContentResource, does not include them. - // To ensure we can access them safely, - // cast the resource to `any` and then try to access them. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function getDimensions(resource: any): { height: number; width: number } { - return { - height: resource.height || 0, - width: resource.width || 0, - }; - } + const paintings: ContentResource[] = []; + const traverse = new Traverse({ + contentResource: [ + (resource) => { + if (resource.type === "Image") { + paintings.push(resource); + } + }, + ], + }); + traverse.traverseCanvasItems(canvas); - function formatCaption(resource: ContentResourceNormalized) { - const { width, height } = getDimensions(resource); - const dimensions = width && height ? `\n(${width} x ${height})` : ""; - return `Placeholder${dimensions}`; + if (!paintings.length) { + return <>; } - const activeCanvas = state.activeCanvas; + const media: Media[] = paintings.reduce((acc, painting, i) => { + if (!painting.id) { + return acc; + } - if (!activeCanvas.placeholderCanvas) { - return <>; + // @ts-expect-error - I don't want to go down the rabbit hole of type checking here + const label = getLabelByUserLanguage(painting?.label); + acc.push({ + type: "image", + id: `painting-${i}`, + // use a smaller size so as to not load large images + // update the size when the media is added + src: updateIIIFImageRequestURI(painting.id, { + size: "!300,300", + }), + caption: formatCaption(label[0] ? label[0] : "Painting", painting), + }); + + return acc; + }, [] as Media[]); + + return ( + <> + {media.map((m) => ( + handleAddMedia(selected, m)} + /> + ))} + + ); +}; + +const Placeholder: FC<{ placeholder: NonNullable }> = ({ + placeholder, +}) => { + const { state, dispatch } = usePlugin(); + + function handleAddMedia(selected: boolean, media: Media) { + handleSelectedMedia(selected, media, state.selectedMedia, (resources) => + dispatch({ type: "setSelectedMedia", selectedMedia: resources }), + ); } - const placeholderResource = state.vault.get({ - type: "Canvas", - id: activeCanvas.placeholderCanvas.id, + const paintings: ContentResource[] = []; + const traverse = new Traverse({ + contentResource: [ + (resource) => { + if (resource.type === "Image") { + paintings.push(resource); + } + }, + ], }); + traverse.traverseCanvasItems(placeholder); - const annotationItems = state.vault.get({ - type: "AnnotationPage", - id: placeholderResource.items.map((item) => item.id), - }); + if (!paintings.length) { + return <>; + } - const annotationPageItems = state.vault.get({ - type: "Annotation", - id: annotationItems.items.map((item) => item.id), - }); + const media: Media[] = paintings.reduce((acc, painting, i) => { + if (!painting.id) { + return acc; + } - const body = state.vault.get({ - type: "ContentResource", - id: annotationPageItems.body.map((b) => b.id), - }); + acc.push({ + type: "image", + id: `placeholder-${i}`, + src: painting.id, + caption: formatCaption("Placeholder", painting), + }); - const id = body.id; - const paintingMedia: Media = { - type: "image", - id: id || "", - src: id || "", - caption: formatCaption(body), - }; + return acc; + }, [] as Media[]); return ( <> - {id && ( + {media.map((m) => ( handleAddMedia(selected, paintingMedia)} + figcaption={m.caption} + imgObjectFit="cover" + initialState={isMediaInSelectedMedia(m, state.selectedMedia) ? "selected" : "unselected"} + key={`placeholder-${m.id}`} + src={m.src} + onSelectionChange={(selected) => handleAddMedia(selected, m)} /> - )} + ))} ); }; -const Thumbnail: FC<{ - thumbnail: CanvasNormalized["thumbnail"][0]; -}> = ({ thumbnail }) => { +const Thumbnails: FC<{ thumbnails: ContentResource[] }> = ({ thumbnails }) => { const { state, dispatch } = usePlugin(); - const resource = state.vault.get({ type: "ContentResource", id: thumbnail.id }); function handleAddMedia(selected: boolean, media: Media) { handleSelectedMedia(selected, media, state.selectedMedia, (resources) => @@ -178,44 +253,32 @@ const Thumbnail: FC<{ ); } - // Though the resource may have `width` and `height` properties - // it's type, ContentResource, does not include them. - // To ensure we can access them safely, - // cast the resource to `any` and then try to access them. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function getDimensions(resource: any): { height: number; width: number } { - return { - height: resource.height || 0, - width: resource.width || 0, - }; - } - - function formatCaption(resource: ContentResourceNormalized) { - const { width, height } = getDimensions(resource); - const dimensions = width && height ? `\n(${width} x ${height})` : ""; - return `Thumbnail${dimensions}`; - } - - const id = thumbnail.id; - const thumbnailMedia: Media = { - type: "image", - id: id || "", - src: id || "", - caption: formatCaption(resource), - }; + const media: Media[] = thumbnails.reduce((acc, thumbnail, i) => { + if (!thumbnail.id) { + return acc; + } + acc.push({ + type: "image", + id: `thumbnail-${i}`, + src: thumbnail.id, + caption: formatCaption("Thumbnail", thumbnail), + }); + return acc; + }, [] as Media[]); return ( <> - {id && ( + {media.map((thumbnail, index) => ( handleAddMedia(selected, thumbnailMedia)} + onSelectionChange={(selected) => handleAddMedia(selected, thumbnail)} /> - )} + ))} ); }; @@ -224,7 +287,7 @@ export const MediaDialog = () => { const { state, dispatch } = usePlugin(); const dialogRef = useRef(null); const initialFocusRef = useRef(null); - const canvasTitle = getLabelByUserLanguage(state.activeCanvas.label)[0]; + const canvasTitle = getLabelByUserLanguage(state.activeCanvas?.label ?? undefined)[0]; function closeDialog() { dispatch({ type: "setMediaDialogState", state: "closed" }); @@ -260,6 +323,14 @@ export const MediaDialog = () => { }; }, [state.mediaDialogState, dispatch]); + // serialize the Canvas so it can traversed + const canvas: Canvas = state.vault.serialize( + { type: "Canvas", id: state.activeCanvas.id }, + serializeConfigPresentation3, + ); + + // NOTE: It is assumed that all media resources are images hosted on the internet + // and that they can be used directly as the `src` for an `` element. return ( {
- - {state.activeCanvas.thumbnail.length > 0 && ( - <> - {state.activeCanvas.thumbnail.map((thumbnail, i) => ( - - ))} - - )} + + {canvas.placeholderCanvas && } + {canvas.thumbnail?.length && }
diff --git a/src/plugin/Panel/MediaDialog/style.module.css b/src/plugin/Panel/MediaDialog/style.module.css index 2091265..31ec4bf 100644 --- a/src/plugin/Panel/MediaDialog/style.module.css +++ b/src/plugin/Panel/MediaDialog/style.module.css @@ -16,10 +16,9 @@ } .content { - /* display: grid; */ - /* grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); */ display: flex; gap: var(--clover-ai-sizes-2); + flex-wrap: wrap; figcaption { --figure-caption-font-size: var(--clover-ai-sizes-3); diff --git a/src/plugin/Panel/index.test.tsx b/src/plugin/Panel/index.test.tsx index f1750e0..388caa5 100644 --- a/src/plugin/Panel/index.test.tsx +++ b/src/plugin/Panel/index.test.tsx @@ -70,6 +70,23 @@ const mockClover = { }; return null; }), + serialize: vi.fn((query) => { + if (query.type === "Canvas") + return { + id: "canvas-1", + type: "Canvas", + label: { en: ["Test Canvas"] }, + thumbnail: [ + { + id: "thumbnail-1", + type: "Image", + format: "image/jpeg", + }, + ], + items: [], + }; + return null; + }), }, activeManifest: "manifest-1", activeCanvas: "canvas-1", diff --git a/src/plugin/Panel/index.tsx b/src/plugin/Panel/index.tsx index ab1b79a..4d9517a 100644 --- a/src/plugin/Panel/index.tsx +++ b/src/plugin/Panel/index.tsx @@ -56,7 +56,7 @@ export function PluginPanelComponent(props: CloverPlugin & PluginProps) { type: "setSystemPrompt", systemPrompt: `You are a helpful assistant that can answer questions about the item in the viewer. Here is the manifest data for the item:\n\n${JSON.stringify(state.manifest["metadata"], null, 2)}`, }); - const label = state.manifest.label; + const label = state.manifest?.label ?? undefined; const title = getLabelByUserLanguage(label); setItemTitle(title.length > 0 ? title[0] : "this item"); } diff --git a/src/plugin/utils/index.ts b/src/plugin/utils/index.ts index cb6afde..01717bf 100644 --- a/src/plugin/utils/index.ts +++ b/src/plugin/utils/index.ts @@ -1,7 +1,7 @@ -import type { ManifestNormalized } from "@iiif/presentation-3-normalized"; +import { InternationalString } from "@iiif/presentation-3"; import type { Message } from "@types"; -export function getLabelByUserLanguage(label: ManifestNormalized["label"]): string[] { +export function getLabelByUserLanguage(label: InternationalString | undefined): string[] { if (!label) { return []; } @@ -50,3 +50,108 @@ export function setMessagesToStorage(messages: Message[]): void { console.warn("Failed to save messages to session storage:", error); } } + +type Scheme = "http" | "https" | (string & {}); +/** @example "example.org" */ +type Server = string; +/** + * @remarks + * If not empty, must start with a leading slash and not end with a trailing slash. + * + * @example "/iiif" */ +type Prefix = `/${string}` | ""; +/** @example "image1" */ +type Identifier = string; +/** @example "full", "x,y,w,h" */ +type Region = string; +/** @example "max", "w,", ",h", "pct:n" */ +type Size = string; +/** @example "0", "90", "!90" */ +type Rotation = string; +/** @example "default", "color", "gray", "bitonal" */ +type Quality = string; +/** @example "jpg", "png", "tif" */ +type Format = string; + +/** + * A IIIF Image Request URI as defined in the IIIF Image API 3.0 specification. + * + * @example + * "https://example.org/iiif/image1/full/max/0/default.jpg" + */ +export type IIIFImageRequestURI = + `${Scheme}://${Server}${Prefix}/${Identifier}/${Region}/${Size}/${Rotation}/${Quality}.${Format}`; + +export interface IIIFImageRequestParams { + scheme: Scheme; + server: Server; + prefix: Prefix; + identifier: Identifier; + region: Region; + size: Size; + rotation: Rotation; + quality: Quality; + format: Format; +} + +/** + * Create a IIIF Image Request URI from its components. + * + */ +export function createIIIFImageRequestURI({ + scheme = "https", + server, + prefix = "", + identifier, + region = "full", + size = "max", + rotation = "0", + quality = "default", + format = "jpg", +}: Partial): IIIFImageRequestURI { + return `${scheme}://${server}${prefix}/${identifier}/${region}/${size}/${rotation}/${quality}.${format}`; +} + +/** + * Update parts of a IIIF Image Request URI while preserving the other components. + * + * @param baseURI a IIIF Image Request URI containing at least the server and identifier + * @param updates the parts of the URI to update + */ +export function updateIIIFImageRequestURI( + baseURI: string, + updates: Partial, +): IIIFImageRequestURI { + const url = new URL(baseURI); + const originalParts = url.pathname.split("/").filter((part) => part); + + // work backwards to pop off the known parts + const qualityAndFormat = originalParts.pop()?.split(".") ?? []; + const quality = qualityAndFormat[0]; + const format = qualityAndFormat[1]; + const rotation = originalParts.pop(); + const size = originalParts.pop(); + const region = originalParts.pop(); + const identifier = originalParts.pop(); + const prefix = originalParts.join("/"); + + if (!identifier) { + throw new Error("Invalid IIIF Image Request URI: Missing identifier"); + } + + if (!url.host) { + throw new Error("Invalid IIIF Image Request URI: Missing server"); + } + + return createIIIFImageRequestURI({ + scheme: url?.protocol?.replace(":", "") ?? "https", + server: url.host, + prefix: updates.prefix ?? (prefix ? `/${prefix}` : ""), + identifier: updates.identifier ?? identifier, + region: updates.region ?? region, + size: updates.size ?? size, + rotation: updates.rotation ?? rotation, + quality: updates.quality ?? quality, + format: updates.format ?? format, + }); +}