From cf5e3fe20811bc8129e035d39434c30c97dee2bd Mon Sep 17 00:00:00 2001 From: charlesLoder Date: Mon, 30 Mar 2026 21:27:15 -0400 Subject: [PATCH 1/9] fix: disable submit button when question is empty --- src/plugin/Panel/ChatInput/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugin/Panel/ChatInput/index.tsx b/src/plugin/Panel/ChatInput/index.tsx index cf5c790..8fb8919 100644 --- a/src/plugin/Panel/ChatInput/index.tsx +++ b/src/plugin/Panel/ChatInput/index.tsx @@ -137,6 +137,7 @@ export const ChatInput: FC = () => { state={formState !== "success" ? formState : undefined} title="Submit question" type="submit" + disabled={!textareaValue.trim()} > From cba18abfe78f31a4bef04f2cbf7c3c64072884ac Mon Sep 17 00:00:00 2001 From: charlesLoder Date: Thu, 9 Apr 2026 23:15:55 -0400 Subject: [PATCH 2/9] initial selected annotation --- src/icons/TextFile.tsx | 18 ++ src/icons/index.ts | 1 + .../ChatInput/SelectedAnnotation/index.tsx | 42 ++++ .../SelectedAnnotation/style.module.css | 17 ++ src/plugin/Panel/ChatInput/index.test.tsx | 1 + src/plugin/Panel/ChatInput/index.tsx | 29 ++- src/plugin/Panel/MediaDialog/index.tsx | 205 ++++++++++++++++-- src/plugin/Panel/MediaDialog/style.module.css | 78 +++++++ src/plugin/Panel/index.test.tsx | 4 +- src/plugin/context/plugin-context.test.tsx | 18 ++ src/plugin/context/plugin-context.tsx | 12 +- src/providers/userTokenProvider/index.tsx | 22 +- src/types.d.ts | 19 +- 13 files changed, 418 insertions(+), 48 deletions(-) create mode 100644 src/icons/TextFile.tsx create mode 100644 src/plugin/Panel/ChatInput/SelectedAnnotation/index.tsx create mode 100644 src/plugin/Panel/ChatInput/SelectedAnnotation/style.module.css diff --git a/src/icons/TextFile.tsx b/src/icons/TextFile.tsx new file mode 100644 index 0000000..00ab6ef --- /dev/null +++ b/src/icons/TextFile.tsx @@ -0,0 +1,18 @@ +export const TextFile: React.FC> = () => { + return ( + + + + ); +}; diff --git a/src/icons/index.ts b/src/icons/index.ts index 0f77f3e..2d61479 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -4,3 +4,4 @@ export { BulletList } from "./BulletList"; export { Clear } from "./Clear"; export { Close } from "./Close"; export { Gear } from "./Gear"; +export { TextFile } from "./TextFile"; diff --git a/src/plugin/Panel/ChatInput/SelectedAnnotation/index.tsx b/src/plugin/Panel/ChatInput/SelectedAnnotation/index.tsx new file mode 100644 index 0000000..7b20127 --- /dev/null +++ b/src/plugin/Panel/ChatInput/SelectedAnnotation/index.tsx @@ -0,0 +1,42 @@ +import { Button } from "@components"; +import { usePlugin } from "@context"; +import { Close, TextFile } from "@icons"; +import type { Annotation } from "@types"; +import style from "./style.module.css"; + +export interface SelectedAnnotationProps { + annotation: Annotation; +} + +export const SelectedAnnotation: React.FC = ({ annotation }) => { + const { dispatch, state } = usePlugin(); + + function handleClick(id: Annotation["id"]) { + const newAnnotations = state.selectedAnnotations.filter((a) => a.id !== id); + dispatch({ type: "setSelectedAnnotations", selectedAnnotations: newAnnotations }); + } + + console.log(annotation.content); + const truncatedContent = + annotation.content.length > 15 + ? annotation.content.substring(0, 15) + "..." + : annotation.content; + + return ( +
+ + + + +
+ ); +}; diff --git a/src/plugin/Panel/ChatInput/SelectedAnnotation/style.module.css b/src/plugin/Panel/ChatInput/SelectedAnnotation/style.module.css new file mode 100644 index 0000000..e0624e6 --- /dev/null +++ b/src/plugin/Panel/ChatInput/SelectedAnnotation/style.module.css @@ -0,0 +1,17 @@ +.selectedAnnotation { + display: grid; + width: 30px; + height: 30px; + + > * { + grid-area: 1/1; + } + + > button { + width: fit-content; + height: fit-content; + transform-origin: bottom right; + translate: 40% 40%; + transform: scale(0.6); + } +} diff --git a/src/plugin/Panel/ChatInput/index.test.tsx b/src/plugin/Panel/ChatInput/index.test.tsx index b3131ff..f31b161 100644 --- a/src/plugin/Panel/ChatInput/index.test.tsx +++ b/src/plugin/Panel/ChatInput/index.test.tsx @@ -57,6 +57,7 @@ describe("ChatInput", () => { dispatch, state: { selectedMedia: [], + selectedAnnotations: [], messages: [], provider, } as any, // eslint-disable-line @typescript-eslint/no-explicit-any, diff --git a/src/plugin/Panel/ChatInput/index.tsx b/src/plugin/Panel/ChatInput/index.tsx index 8fb8919..7c75191 100644 --- a/src/plugin/Panel/ChatInput/index.tsx +++ b/src/plugin/Panel/ChatInput/index.tsx @@ -1,9 +1,10 @@ import { Button, PromptInput } from "@components"; import { usePlugin } from "@context"; import { Add, ArrowUp, Clear } from "@icons"; -import { MediaContent, UserMessage } from "@types"; +import { AnnotationContent, MediaContent, UserMessage } from "@types"; import type { FC } from "react"; import { useState } from "react"; +import { SelectedAnnotation } from "./SelectedAnnotation"; import { SelectedMedia } from "./SelectedMedia"; export const ChatInput: FC = () => { @@ -49,7 +50,18 @@ export const ChatInput: FC = () => { content: media, })); userMessage.content.push(...mediaContent); - dispatch({ type: "setSelectedMedia", selectedMedia: [] }); // Clear selected media after sending + dispatch({ type: "setSelectedMedia", selectedMedia: [] }); + } + + if (state.selectedAnnotations.length) { + const annotationContent: AnnotationContent[] = state.selectedAnnotations.map( + (annotation) => ({ + type: "annotation", + content: annotation, + }), + ); + userMessage.content.push(...annotationContent); + dispatch({ type: "setSelectedAnnotations", selectedAnnotations: [] }); } // Add user message immediately @@ -107,6 +119,13 @@ export const ChatInput: FC = () => { ) : ( <> )} + {state.selectedAnnotations.length ? ( + state.selectedAnnotations.map((annotation, index) => ( + + )) + ) : ( + <> + )}
{PromptInputButtons && } @@ -121,23 +140,23 @@ export const ChatInput: FC = () => { diff --git a/src/plugin/Panel/MediaDialog/index.tsx b/src/plugin/Panel/MediaDialog/index.tsx index 70b2295..83de114 100644 --- a/src/plugin/Panel/MediaDialog/index.tsx +++ b/src/plugin/Panel/MediaDialog/index.tsx @@ -1,16 +1,77 @@ -import { Dialog, Heading, ImageSelect } from "@components"; +import { Button, Dialog, Heading, ImageSelect } from "@components"; import { usePlugin } from "@context"; import { serializeConfigPresentation3, Traverse } from "@iiif/parser"; -import type { Canvas, ContentResource } from "@iiif/presentation-3"; -import type { Media } from "@types"; +import type { + Annotation as IIIFAnnotation, + Canvas, + ContentResource, + EmbeddedResource, + SpecificResource, +} from "@iiif/presentation-3"; +import type { Annotation, Media } from "@types"; import { getLabelByUserLanguage, updateIIIFImageRequestURI } from "@utils"; -import { FC, useEffect, useRef } from "react"; +import { FC, useEffect, useRef, useState } from "react"; import style from "./style.module.css"; +function isEmbeddedResource( + body: unknown, +): body is EmbeddedResource { + return typeof body === "object" && body !== null && "type" in body && (body as EmbeddedResource).type === "TextualBody"; +} + +function isSpecificResource(body: unknown): body is SpecificResource { + return typeof body === "object" && body !== null && "type" in body && (body as SpecificResource).type === "SpecificResource"; +} + +function getTextualBodyValue(body: unknown): string | undefined { + if (!body) return undefined; + + if (Array.isArray(body)) { + for (const item of body) { + const value = getTextualBodyValue(item); + if (value) return value; + } + return undefined; + } + + if (typeof body === "string") { + return body; + } + + if (isEmbeddedResource(body)) { + return body.value; + } + + if (isSpecificResource(body) && body.source) { + return getTextualBodyValue(body.source); + } + + if (typeof body === "object" && "value" in body && typeof (body as { value: unknown }).value === "string") { + return (body as { value: string }).value; + } + + return undefined; +} + +function getTextualBodyValueFromAnnotation(annotation: IIIFAnnotation): string | undefined { + if ("bodyValue" in annotation && typeof annotation.bodyValue === "string") { + return annotation.bodyValue; + } + + return getTextualBodyValue(annotation.body); +} + function isMediaInSelectedMedia(media: Media, selectedMedia: Media[]): boolean { return selectedMedia.some((m) => m.id === media.id); } +function isAnnotationInSelectedAnnotations( + annotation: Annotation, + selectedAnnotations: Annotation[], +): boolean { + return selectedAnnotations.some((a) => a.id === annotation.id); +} + function handleSelectedMedia( selected: boolean, media: Media, @@ -253,7 +314,7 @@ const Thumbnails: FC<{ thumbnails: ContentResource[] }> = ({ thumbnails }) => { ); } - const media: Media[] = thumbnails.reduce((acc, thumbnail, i) => { + const media: Media[] = thumbnails.reduce((acc, thumbnail, i) => { if (!thumbnail.id) { return acc; } @@ -264,7 +325,7 @@ const Thumbnails: FC<{ thumbnails: ContentResource[] }> = ({ thumbnails }) => { caption: formatCaption("Thumbnail", thumbnail), }); return acc; - }, [] as Media[]); + }, []); return ( <> @@ -283,10 +344,93 @@ const Thumbnails: FC<{ thumbnails: ContentResource[] }> = ({ thumbnails }) => { ); }; +const Annotations: FC<{ canvas: Canvas }> = ({ canvas }) => { + const { state, dispatch } = usePlugin(); + + const annotations: Annotation[] = []; + const traverse = new Traverse({ + annotation: [ + (a) => { + const bodyValue = getTextualBodyValueFromAnnotation(a); + if (bodyValue) { + let region = undefined; + if (a.target && typeof a.target === "object" && "source" in a.target) { + const targetSource = a.target.source as string; + const xywhMatch = targetSource.match(/#xywh=(\d+),(\d+),(\d+),(\d+)$/); + if (xywhMatch) { + region = { + height: parseInt(xywhMatch[4], 10), + width: parseInt(xywhMatch[3], 10), + x: parseInt(xywhMatch[1], 10), + y: parseInt(xywhMatch[2], 10), + }; + } + } + + annotations.push({ + content: bodyValue, + id: a.id || `annotation-${annotations.length}`, + region, + target: typeof a.target === "string" ? a.target : undefined, + }); + } + }, + ], + }); + + traverse.traverseCanvas(canvas); + + function handleAddAnnotation(selected: boolean, annotation: Annotation) { + const resources: Annotation[] = selected + ? [...state.selectedAnnotations, annotation] + : state.selectedAnnotations.filter((a) => a.id !== annotation.id); + + dispatch({ type: "setSelectedAnnotations", selectedAnnotations: resources }); + } + + if (!annotations.length) { + return

No annotations found on this canvas.

; + } + + return ( +
+ {annotations.map((annotation) => { + const isSelected = isAnnotationInSelectedAnnotations(annotation, state.selectedAnnotations); + return ( +
+ +
+ ); + })} +
+ ); +}; + +type TabType = "media" | "annotations"; + export const MediaDialog = () => { const { state, dispatch } = usePlugin(); const dialogRef = useRef(null); const initialFocusRef = useRef(null); + const [selectedTab, setSelectedTab] = useState("media"); const canvasTitle = getLabelByUserLanguage(state.activeCanvas?.label ?? undefined)[0]; function closeDialog() { @@ -339,25 +483,50 @@ export const MediaDialog = () => { width="stretched" onCloseCallback={closeDialog} > - {/* - some computations in this component can be expensive - so only render the dialog content when the dialog is open - */} {state.mediaDialogState === "open" && ( <>
- Add media + Add content

- Add media from current canvas {canvasTitle?.length ? `(${canvasTitle})` : ""} to the + Add content from current canvas {canvasTitle?.length ? `(${canvasTitle})` : ""} to the chat

-
-
- - - {canvas.placeholderCanvas && } - {canvas.thumbnail?.length && } +
+
+ + +
+
+ {selectedTab === "media" && ( +
+ + + {canvas.placeholderCanvas && ( + + )} + {canvas.thumbnail?.length && } +
+ )} + {selectedTab === "annotations" && ( +
+ +
+ )}
diff --git a/src/plugin/Panel/MediaDialog/style.module.css b/src/plugin/Panel/MediaDialog/style.module.css index 31ec4bf..ffd10c9 100644 --- a/src/plugin/Panel/MediaDialog/style.module.css +++ b/src/plugin/Panel/MediaDialog/style.module.css @@ -10,6 +10,36 @@ font-style: italic; } +.tabsContainer { + display: grid; + grid-template-columns: auto 1fr; +} + +.tabsList { + display: flex; + flex-direction: column; + gap: var(--clover-ai-space-2); + padding-inline-end: var(--clover-ai-space-4); +} + +.tabButton { + width: 100%; + justify-content: flex-start; +} + +.tabDetails { + display: flex; + flex-direction: column; + gap: var(--clover-ai-space-4); + border-left: 1px solid var(--clover-ai-colors-primary); + padding-inline: var(--clover-ai-space-4); + overflow-wrap: anywhere; + + * { + margin: 0; + } +} + .contentContainer { container-name: content-container; container-type: inline-size; @@ -24,3 +54,51 @@ --figure-caption-font-size: var(--clover-ai-sizes-3); } } + +.emptyMessage { + color: var(--clover-ai-colors-text-secondary); + font-style: italic; +} + +.annotationsList { + display: flex; + flex-direction: column; + gap: var(--clover-ai-space-3); +} + +.annotationItem { + display: flex; + flex-direction: column; + gap: var(--clover-ai-space-2); +} + +.annotationLabel { + display: flex; + align-items: flex-start; + gap: var(--clover-ai-space-2); + cursor: pointer; + + input[type="checkbox"] { + margin-top: var(--clover-ai-space-1); + } +} + +.annotationPreview { + display: flex; + flex-direction: column; + gap: var(--clover-ai-space-1); +} + +.annotationContent { + font-size: var(--clover-ai-sizes-3); + line-height: 1.4; + + p { + margin: 0; + } +} + +.annotationRegion { + font-size: var(--clover-ai-sizes-2); + color: var(--clover-ai-colors-text-secondary); +} diff --git a/src/plugin/Panel/index.test.tsx b/src/plugin/Panel/index.test.tsx index 09f2b4c..7f9cd9d 100644 --- a/src/plugin/Panel/index.test.tsx +++ b/src/plugin/Panel/index.test.tsx @@ -168,13 +168,13 @@ describe("Plugin > Panel", () => { const { container } = render(); // 1. Open the media dialog - const addMediaButton = screen.getByRole("button", { name: "Add media" }); + const addMediaButton = screen.getByRole("button", { name: "Add content" }); await user.click(addMediaButton); // 2. Dialog is open const dialog = screen.getByRole("dialog"); expect(dialog).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Add media" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Add content" })).toBeInTheDocument(); // 3. Select an image const imageSelect = await screen.findByRole("button", { diff --git a/src/plugin/context/plugin-context.test.tsx b/src/plugin/context/plugin-context.test.tsx index 3199779..e180c0d 100644 --- a/src/plugin/context/plugin-context.test.tsx +++ b/src/plugin/context/plugin-context.test.tsx @@ -30,6 +30,7 @@ const mockInitialState: PluginContextStore = { openSeaDragonViewer: undefined, provider: undefined, selectedMedia: [], + selectedAnnotations: [], systemPrompt: "", // eslint-disable-next-line @typescript-eslint/no-explicit-any vault: {} as any, @@ -169,6 +170,23 @@ describe("pluginReducer", () => { expect(newState.selectedMedia).toEqual(selectedMedia); }); + it("should handle setSelectedAnnotations", () => { + const selectedAnnotations = [ + { + id: "annotation-1", + content: "

Test annotation

", + region: { x: 0, y: 0, width: 100, height: 100 }, + }, + ]; + const action: PluginContextActions = { + type: "setSelectedAnnotations", + selectedAnnotations, + }; + const newState = pluginReducer(mockInitialState, action); + + expect(newState.selectedAnnotations).toEqual(selectedAnnotations); + }); + it("should handle setOpenSeaDragonViewer", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const openSeaDragonViewer = { id: "osd-viewer" } as any; diff --git a/src/plugin/context/plugin-context.tsx b/src/plugin/context/plugin-context.tsx index 2985605..53d9853 100644 --- a/src/plugin/context/plugin-context.tsx +++ b/src/plugin/context/plugin-context.tsx @@ -1,7 +1,7 @@ import type { Vault } from "@iiif/helpers"; import type { CanvasNormalized, ManifestNormalized } from "@iiif/presentation-3-normalized"; import type { Plugin as CloverIIIF } from "@samvera/clover-iiif"; -import type { ConversationState, Media, Message } from "@types"; +import type { Annotation, ConversationState, Media, Message } from "@types"; import { loadMessagesFromStorage, setMessagesToStorage } from "@utils"; import type { Viewer } from "openseadragon"; import type { Dispatch } from "react"; @@ -17,6 +17,7 @@ export interface PluginContextStore { openSeaDragonViewer: Viewer | undefined; provider: BaseProvider | undefined; selectedMedia: Media[]; + selectedAnnotations: Annotation[]; systemPrompt: string; vault: Vault; } @@ -61,6 +62,11 @@ interface SetSelectedMediaAction { type: "setSelectedMedia"; } +interface SetSelectedAnnotationsAction { + selectedAnnotations: Annotation[]; + type: "setSelectedAnnotations"; +} + interface SetVaultAction { vault: Vault; type: "setVault"; @@ -90,6 +96,7 @@ export type PluginContextActions = | SetMediaDialogStateAction | SetOSDViewerAction | SetSelectedMediaAction + | SetSelectedAnnotationsAction | SetSystemPromptAction | SetVaultAction | UpdateProviderAction @@ -105,6 +112,7 @@ const defaultPluginContextStore: InitPluginContextStore = { openSeaDragonViewer: undefined, provider: undefined, selectedMedia: [], + selectedAnnotations: [], systemPrompt: "", }; @@ -161,6 +169,8 @@ export function pluginReducer( return { ...state, mediaDialogState: action.state }; case "setSelectedMedia": return { ...state, selectedMedia: action.selectedMedia }; + case "setSelectedAnnotations": + return { ...state, selectedAnnotations: action.selectedAnnotations }; case "setOpenSeaDragonViewer": return { ...state, openSeaDragonViewer: action.openSeaDragonViewer }; case "setVault": diff --git a/src/providers/userTokenProvider/index.tsx b/src/providers/userTokenProvider/index.tsx index 16d5463..f70d950 100644 --- a/src/providers/userTokenProvider/index.tsx +++ b/src/providers/userTokenProvider/index.tsx @@ -122,25 +122,6 @@ export class UserTokenProvider extends BaseProvider { serializeConfigPresentation3, ); - const annotationTexts: string[] = []; - const traverse = new Traverse({ - annotation: [ - (a) => { - if ( - a.body && - typeof a.body === "object" && - "type" in a.body && - a.body.type === "TextualBody" && - a.body.value - ) { - annotationTexts.push(a.body.value); - } - }, - ], - }); - - traverse.traverseCanvas(canvas); - // prettier-ignore context = dedent.withOptions({ alignValues: true })` ## Context @@ -148,8 +129,7 @@ export class UserTokenProvider extends BaseProvider { Use this information if possible to inform your answer. ### Canvas${canvas.label ? ` - - Label: ${getLabelByUserLanguage(canvas.label)[0]}` : ""}${annotationTexts.length ? ` - - Annotations: ${annotationTexts.join(", ")}` : ""} + - Label: ${getLabelByUserLanguage(canvas.label)[0]}` : ""} `; } diff --git a/src/types.d.ts b/src/types.d.ts index 24cc98f..de90fd7 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -10,6 +10,18 @@ export type Media = { caption?: string; }; +export type Annotation = { + content: string; + id: string; + region?: { + height: number; + width: number; + x: number; + y: number; + }; + target?: string; +}; + export interface TextContent { content: string; type: "text"; @@ -20,6 +32,11 @@ export interface MediaContent { type: "media"; } +export interface AnnotationContent { + content: Annotation; + type: "annotation"; +} + export interface ToolContent extends TextContent { tool_name: string; } @@ -39,7 +56,7 @@ export type AssistantMessage = { } & (Response | ToolCall); export interface UserMessage { - content: (TextContent | MediaContent)[]; + content: (TextContent | MediaContent | AnnotationContent)[]; /** Context that can be added to user messages when generating a response */ context: { canvas: CanvasNormalized; From b508275400224c98cbf4b9e22196d8371e88cc56 Mon Sep 17 00:00:00 2001 From: charlesLoder Date: Thu, 9 Apr 2026 23:20:10 -0400 Subject: [PATCH 3/9] minor tweals --- src/icons/TextFile.tsx | 2 +- src/plugin/Panel/ChatInput/SelectedAnnotation/index.tsx | 1 - .../Panel/ChatInput/SelectedAnnotation/style.module.css | 4 ++++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/icons/TextFile.tsx b/src/icons/TextFile.tsx index 00ab6ef..39604a9 100644 --- a/src/icons/TextFile.tsx +++ b/src/icons/TextFile.tsx @@ -1,7 +1,7 @@ export const TextFile: React.FC> = () => { return ( = ({ annotati dispatch({ type: "setSelectedAnnotations", selectedAnnotations: newAnnotations }); } - console.log(annotation.content); const truncatedContent = annotation.content.length > 15 ? annotation.content.substring(0, 15) + "..." diff --git a/src/plugin/Panel/ChatInput/SelectedAnnotation/style.module.css b/src/plugin/Panel/ChatInput/SelectedAnnotation/style.module.css index e0624e6..5cf0c87 100644 --- a/src/plugin/Panel/ChatInput/SelectedAnnotation/style.module.css +++ b/src/plugin/Panel/ChatInput/SelectedAnnotation/style.module.css @@ -7,6 +7,10 @@ grid-area: 1/1; } + .annotationContent svg { + color: var(--clover-ai-colors-accentAlt); + } + > button { width: fit-content; height: fit-content; From bb030175b86360e5f8c20339482c542dad26a101 Mon Sep 17 00:00:00 2001 From: charlesLoder Date: Thu, 9 Apr 2026 23:22:20 -0400 Subject: [PATCH 4/9] rename class --- src/plugin/Panel/ChatInput/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugin/Panel/ChatInput/index.tsx b/src/plugin/Panel/ChatInput/index.tsx index 7c75191..853b7ae 100644 --- a/src/plugin/Panel/ChatInput/index.tsx +++ b/src/plugin/Panel/ChatInput/index.tsx @@ -113,7 +113,7 @@ export const ChatInput: FC = () => { } }} > -
+
{state.selectedMedia.length ? ( state.selectedMedia.map((media, index) => ) ) : ( From 4740a8b633e15c98a51800cca3beeb9d13107706 Mon Sep 17 00:00:00 2001 From: charlesLoder Date: Thu, 9 Apr 2026 23:24:06 -0400 Subject: [PATCH 5/9] remove invalid test - it was no longer needed --- src/plugin/Panel/ChatInput/index.test.tsx | 56 +---------------------- 1 file changed, 1 insertion(+), 55 deletions(-) diff --git a/src/plugin/Panel/ChatInput/index.test.tsx b/src/plugin/Panel/ChatInput/index.test.tsx index f31b161..00cfaaa 100644 --- a/src/plugin/Panel/ChatInput/index.test.tsx +++ b/src/plugin/Panel/ChatInput/index.test.tsx @@ -1,10 +1,6 @@ -import * as context from "@context"; import "@testing-library/jest-dom/vitest"; -import { cleanup, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import React from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ChatInput } from "./index"; +import { vi } from "vitest"; vi.mock("@components", async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -39,53 +35,3 @@ vi.mock("@components", async (importOriginal) => { }, }; }); - -describe("ChatInput", () => { - afterEach(() => { - vi.clearAllMocks(); - cleanup(); - }); - - it("shows an error on empty submission and clears it on valid input", async () => { - const user = userEvent.setup(); - const dispatch = vi.fn(); - const provider = { - send_messages: vi.fn().mockResolvedValue(undefined), - }; - - vi.spyOn(context, "usePlugin").mockImplementation(() => ({ - dispatch, - state: { - selectedMedia: [], - selectedAnnotations: [], - messages: [], - provider, - } as any, // eslint-disable-line @typescript-eslint/no-explicit-any, - })); - - render(); - - const submitButton = screen.getByLabelText("Submit question"); - const textarea = screen.getByRole("textbox"); - - // 1. Submit with empty input - await user.click(submitButton); - - // 2. Check for error message - let errorMessage = await screen.findByText("Please enter a message."); - expect(errorMessage).toBeInTheDocument(); - - // 3. Type only whitespace - await user.clear(textarea); - await user.type(textarea, " "); - errorMessage = await screen.findByText("Please enter a message."); - expect(errorMessage).toBeInTheDocument(); - - // 4. Type valid input - await user.clear(textarea); - await user.type(textarea, "hello"); - - // 5. Error message should disappear - expect(screen.queryByText("Please enter a message.")).not.toBeInTheDocument(); - }); -}); From 37fc38496fbb6558353973d63f64bc435495f49e Mon Sep 17 00:00:00 2001 From: charlesLoder Date: Thu, 9 Apr 2026 23:53:03 -0400 Subject: [PATCH 6/9] refactor to selected content --- .../ChatInput/SelectedAnnotation/index.tsx | 6 +- .../Panel/ChatInput/SelectedMedia/index.tsx | 6 +- src/plugin/Panel/ChatInput/index.tsx | 45 +++++---------- src/plugin/Panel/MediaDialog/index.tsx | 56 +++++++++---------- src/plugin/context/plugin-context.test.tsx | 40 ++++--------- src/plugin/context/plugin-context.tsx | 31 ++++------ src/types.d.ts | 2 + 7 files changed, 74 insertions(+), 112 deletions(-) diff --git a/src/plugin/Panel/ChatInput/SelectedAnnotation/index.tsx b/src/plugin/Panel/ChatInput/SelectedAnnotation/index.tsx index e9a1173..efa969b 100644 --- a/src/plugin/Panel/ChatInput/SelectedAnnotation/index.tsx +++ b/src/plugin/Panel/ChatInput/SelectedAnnotation/index.tsx @@ -12,8 +12,10 @@ export const SelectedAnnotation: React.FC = ({ annotati const { dispatch, state } = usePlugin(); function handleClick(id: Annotation["id"]) { - const newAnnotations = state.selectedAnnotations.filter((a) => a.id !== id); - dispatch({ type: "setSelectedAnnotations", selectedAnnotations: newAnnotations }); + const newContent = state.selectedContent.filter( + (item) => !(item.type === "annotation" && item.content.id === id), + ); + dispatch({ type: "setSelectedContent", selectedContent: newContent }); } const truncatedContent = diff --git a/src/plugin/Panel/ChatInput/SelectedMedia/index.tsx b/src/plugin/Panel/ChatInput/SelectedMedia/index.tsx index 84ac1e9..af156d2 100644 --- a/src/plugin/Panel/ChatInput/SelectedMedia/index.tsx +++ b/src/plugin/Panel/ChatInput/SelectedMedia/index.tsx @@ -11,8 +11,10 @@ export const SelectedMedia: React.FC = ({ media }) => { const { dispatch, state } = usePlugin(); function handleClick(id: Media["id"]) { - const newMedia = state.selectedMedia.filter((m) => m.id !== id); - dispatch({ type: "setSelectedMedia", selectedMedia: newMedia }); + const newContent = state.selectedContent.filter( + (item) => !(item.type === "media" && item.content.id === id), + ); + dispatch({ type: "setSelectedContent", selectedContent: newContent }); } return (
diff --git a/src/plugin/Panel/ChatInput/index.tsx b/src/plugin/Panel/ChatInput/index.tsx index 853b7ae..cca1801 100644 --- a/src/plugin/Panel/ChatInput/index.tsx +++ b/src/plugin/Panel/ChatInput/index.tsx @@ -1,7 +1,7 @@ import { Button, PromptInput } from "@components"; import { usePlugin } from "@context"; import { Add, ArrowUp, Clear } from "@icons"; -import { AnnotationContent, MediaContent, UserMessage } from "@types"; +import { UserMessage } from "@types"; import type { FC } from "react"; import { useState } from "react"; import { SelectedAnnotation } from "./SelectedAnnotation"; @@ -44,24 +44,9 @@ export const ChatInput: FC = () => { }, }; - if (state.selectedMedia.length) { - const mediaContent: MediaContent[] = state.selectedMedia.map((media) => ({ - type: "media", - content: media, - })); - userMessage.content.push(...mediaContent); - dispatch({ type: "setSelectedMedia", selectedMedia: [] }); - } - - if (state.selectedAnnotations.length) { - const annotationContent: AnnotationContent[] = state.selectedAnnotations.map( - (annotation) => ({ - type: "annotation", - content: annotation, - }), - ); - userMessage.content.push(...annotationContent); - dispatch({ type: "setSelectedAnnotations", selectedAnnotations: [] }); + if (state.selectedContent.length) { + userMessage.content.push(...state.selectedContent); + dispatch({ type: "setSelectedContent", selectedContent: [] }); } // Add user message immediately @@ -114,18 +99,16 @@ export const ChatInput: FC = () => { }} >
- {state.selectedMedia.length ? ( - state.selectedMedia.map((media, index) => ) - ) : ( - <> - )} - {state.selectedAnnotations.length ? ( - state.selectedAnnotations.map((annotation, index) => ( - - )) - ) : ( - <> - )} + {state.selectedContent.map((item, index) => { + switch (item.type) { + case "media": + return ; + case "annotation": + return ; + default: + return <>; + } + })}
{PromptInputButtons && } diff --git a/src/plugin/Panel/MediaDialog/index.tsx b/src/plugin/Panel/MediaDialog/index.tsx index 83de114..a1eb49e 100644 --- a/src/plugin/Panel/MediaDialog/index.tsx +++ b/src/plugin/Panel/MediaDialog/index.tsx @@ -8,7 +8,7 @@ import type { EmbeddedResource, SpecificResource, } from "@iiif/presentation-3"; -import type { Annotation, Media } from "@types"; +import { Annotation, Media, SelectedContent } from "@types"; import { getLabelByUserLanguage, updateIIIFImageRequestURI } from "@utils"; import { FC, useEffect, useRef, useState } from "react"; import style from "./style.module.css"; @@ -61,26 +61,26 @@ function getTextualBodyValueFromAnnotation(annotation: IIIFAnnotation): string | return getTextualBodyValue(annotation.body); } -function isMediaInSelectedMedia(media: Media, selectedMedia: Media[]): boolean { - return selectedMedia.some((m) => m.id === media.id); +function isMediaInSelectedContent(media: Media, selectedContent: SelectedContent[]): boolean { + return selectedContent.some((item) => item.type === "media" && item.content.id === media.id); } -function isAnnotationInSelectedAnnotations( +function isAnnotationInSelectedContent( annotation: Annotation, - selectedAnnotations: Annotation[], + selectedContent: SelectedContent[], ): boolean { - return selectedAnnotations.some((a) => a.id === annotation.id); + return selectedContent.some((item) => item.type === "annotation" && item.content.id === annotation.id); } function handleSelectedMedia( selected: boolean, media: Media, - selectedMedia: Media[], - onUpdate: (media: Media[]) => void, + selectedContent: SelectedContent[], + onUpdate: (content: SelectedContent[]) => void, ) { - const resources: Media[] = selected - ? [...selectedMedia, media] - : selectedMedia.filter((m) => m.id !== media.id); + const resources: SelectedContent[] = selected + ? [...selectedContent, { type: "media", content: media }] + : selectedContent.filter((item) => !(item.type === "media" && item.content.id === media.id)); onUpdate(resources); } @@ -120,8 +120,8 @@ const CurrentView = () => { const { state, dispatch } = usePlugin(); function handleAddMedia(selected: boolean, media: Media) { - handleSelectedMedia(selected, media, state.selectedMedia, (resources) => - dispatch({ type: "setSelectedMedia", selectedMedia: resources }), + handleSelectedMedia(selected, media, state.selectedContent, (resources) => + dispatch({ type: "setSelectedContent", selectedContent: resources }), ); } @@ -172,7 +172,7 @@ const CurrentView = () => { imgObjectFit="contain" src={fragmentMedia.src} initialState={ - isMediaInSelectedMedia(fragmentMedia, state.selectedMedia) ? "selected" : "unselected" + isMediaInSelectedContent(fragmentMedia, state.selectedContent) ? "selected" : "unselected" } onSelectionChange={(selected) => handleAddMedia(selected, fragmentMedia)} /> @@ -189,8 +189,8 @@ const Paintings: FC<{ canvas: Canvas }> = ({ canvas }) => { media.src = updateIIIFImageRequestURI(media.src, { size: `max`, }); - handleSelectedMedia(selected, media, state.selectedMedia, (resources) => - dispatch({ type: "setSelectedMedia", selectedMedia: resources }), + handleSelectedMedia(selected, media, state.selectedContent, (resources) => + dispatch({ type: "setSelectedContent", selectedContent: resources }), ); } @@ -237,7 +237,7 @@ const Paintings: FC<{ canvas: Canvas }> = ({ canvas }) => { handleAddMedia(selected, m)} @@ -253,8 +253,8 @@ const Placeholder: FC<{ placeholder: NonNullable }> const { state, dispatch } = usePlugin(); function handleAddMedia(selected: boolean, media: Media) { - handleSelectedMedia(selected, media, state.selectedMedia, (resources) => - dispatch({ type: "setSelectedMedia", selectedMedia: resources }), + handleSelectedMedia(selected, media, state.selectedContent, (resources) => + dispatch({ type: "setSelectedContent", selectedContent: resources }), ); } @@ -295,7 +295,7 @@ const Placeholder: FC<{ placeholder: NonNullable }> handleAddMedia(selected, m)} @@ -309,8 +309,8 @@ const Thumbnails: FC<{ thumbnails: ContentResource[] }> = ({ thumbnails }) => { const { state, dispatch } = usePlugin(); function handleAddMedia(selected: boolean, media: Media) { - handleSelectedMedia(selected, media, state.selectedMedia, (resources) => - dispatch({ type: "setSelectedMedia", selectedMedia: resources }), + handleSelectedMedia(selected, media, state.selectedContent, (resources) => + dispatch({ type: "setSelectedContent", selectedContent: resources }), ); } @@ -335,7 +335,7 @@ const Thumbnails: FC<{ thumbnails: ContentResource[] }> = ({ thumbnails }) => { key={`thumbnail-${index}`} src={thumbnail.src} initialState={ - isMediaInSelectedMedia(thumbnail, state.selectedMedia) ? "selected" : "unselected" + isMediaInSelectedContent(thumbnail, state.selectedContent) ? "selected" : "unselected" } onSelectionChange={(selected) => handleAddMedia(selected, thumbnail)} /> @@ -381,11 +381,11 @@ const Annotations: FC<{ canvas: Canvas }> = ({ canvas }) => { traverse.traverseCanvas(canvas); function handleAddAnnotation(selected: boolean, annotation: Annotation) { - const resources: Annotation[] = selected - ? [...state.selectedAnnotations, annotation] - : state.selectedAnnotations.filter((a) => a.id !== annotation.id); + const resources: SelectedContent[] = selected + ? [...state.selectedContent, { type: "annotation", content: annotation }] + : state.selectedContent.filter((item) => !(item.type === "annotation" && item.content.id === annotation.id)); - dispatch({ type: "setSelectedAnnotations", selectedAnnotations: resources }); + dispatch({ type: "setSelectedContent", selectedContent: resources }); } if (!annotations.length) { @@ -395,7 +395,7 @@ const Annotations: FC<{ canvas: Canvas }> = ({ canvas }) => { return (
{annotations.map((annotation) => { - const isSelected = isAnnotationInSelectedAnnotations(annotation, state.selectedAnnotations); + const isSelected = isAnnotationInSelectedContent(annotation, state.selectedContent); return (