diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9498e9e..63843b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - branches: [master] + branches: [main] jobs: ci: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bea2ca5..0d0fb4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,10 @@ jobs: publish: needs: verify runs-on: ubuntu-latest + environment: npm-publish + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v4 @@ -57,12 +61,10 @@ jobs: - name: Publish to npm run: | if [[ "$GITHUB_REF_NAME" == *-* ]]; then - npm publish --tag beta + npm publish --provenance --access public --tag beta else - npm publish + npm publish --provenance --access public fi - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} github-release: needs: publish diff --git a/.gitignore b/.gitignore index bf3f731..7c2041c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ test-results/ playwright-report/ .DS_Store +docs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c7b3df7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,126 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +jQuery Image Annotation Plugin - creates Flickr-like comment annotations on images embedded in web pages. Users can draw rectangular regions on images, add text notes, and save/load annotations via AJAX or static data. Licensed under GNU GPL v2. + +**Runtime dependencies:** jQuery 3.x/4.x. + +## Build Commands + +```sh +# Install all dependencies +npm install + +# Build (type-check, bundle core + jQuery adapters, minify CSS) +npm run build + +# Type-check only (no emit) +npm run build:check + +# Clean dist directory only +npm run clean + +# Run tests +npm test + +# Run tests against jQuery 4 +npm run test:jquery4 +``` + +## Architecture + +The plugin is written in TypeScript with vanilla DOM internals. jQuery is only used in the thin adapter layer (`src/jquery.annotate.ts`) that registers `$.fn.annotateImage`. Drag/resize uses vanilla pointer events (`src/interactions.ts`). + +### Repository Structure + +``` +src/ + index.ts - Core entry point (annotate() factory, type exports) + types.ts - Shared interfaces (AnnotationNote, AnnotateImageOptions, InteractionHandlers) + annotate-image.ts - AnnotateImage class (canvas, load/save, hover, add note, destroy) + annotate-edit.ts - AnnotateEdit class (edit mode, drag/resize area, form, save/delete/cancel) + annotate-view.ts - AnnotateView class (annotation display, hover, click-to-edit) + interactions.ts - Vanilla drag/resize via pointer events (InteractionHandlers) + jquery.annotate.ts - jQuery adapter ($.fn.annotateImage registration + destroy dispatch) + annotation.css - Plugin styles (icons are inline SVG data URIs) +demo/ + static.html - Demo with hardcoded annotations + ajax.html - Demo with AJAX endpoints + fixtures/ - Mock AJAX endpoint files (get.json, save.json, delete.json) + images/ - Demo-only images +test/ - Vitest test suite +dist/ - Built output (gitignored) +docs/ - Migration plans and design documents +``` + +### Class Structure + +- **`AnnotateImage`** (`src/annotate-image.ts`) — Orchestrates the plugin. Wraps the target image in a canvas div with view/edit overlays (image provides intrinsic sizing, overlays use CSS `inset: 0`). Loads annotations (static or via `fetch`), manages mode switching, creates icon-only "Add Note" button inside the canvas (hover-to-show, always visible on touch). Coordinates stored in natural image pixels; `toRendered()`/`toNatural()` convert between natural and scaled coordinates. Rescale is deferred during active edits. Instance stored via `$(img).data('annotateImage')`. +- **`AnnotateEdit`** (`src/annotate-edit.ts`) — Edit mode. Manages the draggable/resizable area (via injected `InteractionHandlers`), inline form with textarea, save/delete/cancel buttons. Uses `api.save`/`api.delete` callbacks for persistence. +- **`AnnotateView`** (`src/annotate-view.ts`) — View mode. Renders annotation area + tooltip, hover show/hide, click-to-edit for editable annotations. Helper functions `readInlinePosition`/`readInlineSize` read from inline styles (jsdom-compatible). + +### Core API + +`src/index.ts` is the vanilla entry point. It exports: +- `annotate(img, options?)` — factory function, returns `AnnotateImage` instance +- `AnnotateImage` class (for typing/instanceof) +- `AnnotationNote`, `AnnotateImageOptions` types + +Unified defaults (both core and jQuery): `editable: true`, `notes: []`, `autoResize: true`. + +### jQuery Adapter + +`src/jquery.annotate.ts` is the jQuery entry point. It: +- Registers `$.fn.annotateImage(options)` which creates an `AnnotateImage` instance +- Supports method dispatch: `$(img).annotateImage('destroy')` +- Stores the instance via `$(img).data('annotateImage')` + +### Plugin Options + +```typescript +{ + editable: true, // Enable editing + notes: [], // Static annotation data + api?: { // Server persistence (omit for static-only mode) + load: string | (() => Promise), + save: string | ((note: NoteData) => Promise), + delete: string | ((note: NoteData) => Promise), + }, + labels?: { // Configurable button labels (omit for English defaults) + addNote?: string, // default: "Add Note" + save?: string, // default: "OK" + delete?: string, // default: "Delete" + cancel?: string, // default: "Cancel" + placeholder?: string // default: "" (no placeholder) + }, + autoResize?: boolean, // Re-scale on container resize (default: true) + onError?: (context: AnnotateErrorContext) => void, +} +``` + +Each `api` field accepts either a URL string (shorthand for default fetch) or a function (full control). Omitting `api` entirely means static mode — no network calls. + +### Annotation Data Shape + +```typescript +{ top: number, left: number, width: number, height: number, text: string, id: string, editable: boolean } +``` + +### Build Output + +`dist/` contains the built artifacts (gitignored): +- `dist/core.js` — Core library (ESM, no dependencies) +- `dist/core.min.js` — Core library (IIFE, minified, `AnnotateImage` global) +- `dist/jquery.js` — jQuery adapter (ESM, jQuery external) +- `dist/jquery.min.js` — jQuery adapter (IIFE, minified, jQuery external) +- `dist/css/annotate.min.css` — Minified styles (icons embedded as SVG data URIs) +- `dist/types/` — TypeScript declaration files (`.d.ts`) + +Package exports: `"."` (core), `"./jquery"` (jQuery adapter), `"./css"` (styles). + +### CSS Classes + +All classes are prefixed `image-annotate-` (e.g., `.image-annotate-canvas`, `.image-annotate-view`, `.image-annotate-edit`, `.image-annotate-area`, `.image-annotate-note`). diff --git a/demo/ajax.html b/demo/ajax.html index 31cf394..c9539fe 100644 --- a/demo/ajax.html +++ b/demo/ajax.html @@ -21,6 +21,7 @@ Multiple Instances Programmatic API Custom Labels + Scaling

AJAX

diff --git a/demo/custom-labels.html b/demo/custom-labels.html index 3908e12..f0c6637 100644 --- a/demo/custom-labels.html +++ b/demo/custom-labels.html @@ -19,6 +19,7 @@ Multiple Instances Programmatic API Custom Labels + Scaling

Custom Labels

diff --git a/demo/index.html b/demo/index.html index a051579..13b06b3 100644 --- a/demo/index.html +++ b/demo/index.html @@ -18,6 +18,7 @@ Multiple Instances Programmatic API Custom Labels + Scaling

jQuery Image Annotate

@@ -55,6 +56,10 @@

jQuery Image Annotate

Custom Labels Override button labels for internationalization or icon-only mode. +
  • + Scaling + Annotations scale automatically with CSS-constrained, explicit-size, and responsive images. +
  • diff --git a/demo/jquery-basics.html b/demo/jquery-basics.html index 7fe5d43..2b53097 100644 --- a/demo/jquery-basics.html +++ b/demo/jquery-basics.html @@ -21,6 +21,7 @@ Multiple Instances Programmatic API Custom Labels + Scaling

    jQuery Basics

    diff --git a/demo/multiple-instances.html b/demo/multiple-instances.html index 8468d71..c66c996 100644 --- a/demo/multiple-instances.html +++ b/demo/multiple-instances.html @@ -21,6 +21,7 @@ Multiple Instances Programmatic API Custom Labels + Scaling

    Multiple Instances

    diff --git a/demo/programmatic-api.html b/demo/programmatic-api.html index 9da413a..f8b5564 100644 --- a/demo/programmatic-api.html +++ b/demo/programmatic-api.html @@ -19,6 +19,7 @@ Multiple Instances Programmatic API Custom Labels + Scaling

    Programmatic API

    diff --git a/demo/react.html b/demo/react.html index 0058e48..fa3d95a 100644 --- a/demo/react.html +++ b/demo/react.html @@ -29,6 +29,7 @@ Multiple Instances Programmatic API Custom Labels + Scaling

    React

    diff --git a/demo/scaling.html b/demo/scaling.html new file mode 100644 index 0000000..112ae8e --- /dev/null +++ b/demo/scaling.html @@ -0,0 +1,95 @@ + + + + + + Scaling — Image Annotate + + + + + + +
    +

    Scaling

    +

    Annotations scale automatically to match the rendered image size. Coordinates are always stored in natural (original) image pixels.

    + +
    +

    CSS-Constrained (max-width: 500px)

    +

    The image is 960×760 but constrained to 500px wide by CSS. Annotations scale proportionally.

    +
    + Starry Night +
    +
    + +
    +

    Explicit Smaller Attributes (width=400)

    +

    The image is displayed at 400×317 via HTML attributes on a 960×760 original.

    + Starry Night +
    + +
    +

    Responsive Container (50% width)

    +

    The image fills 50% of the content area. Resize the browser window to see annotations reposition.

    +
    + Starry Night +
    +

    Try resizing the browser window.

    +
    +
    + + + + diff --git a/demo/vanilla-basics.html b/demo/vanilla-basics.html index 26fa96d..4754c1a 100644 --- a/demo/vanilla-basics.html +++ b/demo/vanilla-basics.html @@ -19,6 +19,7 @@ Multiple Instances Programmatic API Custom Labels + Scaling

    Vanilla JS

    diff --git a/demo/vue.html b/demo/vue.html index b3f5567..3c4cc1f 100644 --- a/demo/vue.html +++ b/demo/vue.html @@ -26,6 +26,7 @@ Multiple Instances Programmatic API Custom Labels + Scaling

    Vue

    diff --git a/dist/css/annotate.min.css b/dist/css/annotate.min.css index 93d3d45..ec8aa8f 100644 --- a/dist/css/annotate.min.css +++ b/dist/css/annotate.min.css @@ -1 +1 @@ -.image-annotate-add{background:url(../images/asterisk_yellow.png) 3px 3px no-repeat #fff;border:1px solid #ccc!important;color:#000!important;cursor:pointer;display:block;float:left;font-family:Verdana,Sans-Serif;font-size:12px;height:18px;line-height:18px;padding:2px 0 2px 24px;margin:5px 0;width:64px;text-decoration:none}.image-annotate-add:hover{background-color:#eee}.image-annotate-canvas{border:1px solid #ccc;background-position:left top;background-repeat:no-repeat;display:block;margin:0;position:relative}.image-annotate-view{display:none;position:relative}.image-annotate-area{border:1px solid #000;position:absolute}.image-annotate-area div{border:1px solid #FFF;display:block}.image-annotate-area-hover div{border-color:#ff0!important}.image-annotate-area-editable{cursor:pointer}.image-annotate-area-editable-hover div{border-color:#00AD00!important}.image-annotate-note{background:#E7FFE7;border:1px solid #397F39;color:#000;display:none;font-family:Verdana,Sans-Serif;font-size:12px;max-width:200px;padding:3px 7px;position:absolute}.image-annotate-note .actions{display:block;font-size:80%}.image-annotate-edit{display:none}#image-annotate-edit-form{background:#FFFEE3;border:1px solid #000;height:78px;padding:7px;position:absolute;width:250px}#image-annotate-edit-form form{clear:right;margin:0!important;padding:0;z-index:999}#image-annotate-edit-form .box{margin:0}#image-annotate-edit-form #edit-comment-wrapper textarea,#image-annotate-edit-form input.form-text{width:90%}#image-annotate-edit-form textarea{height:50px;font-family:Verdana,Sans-Serif;font-size:12px;width:248px}#image-annotate-edit-form fieldset{background:0 0}#image-annotate-edit-form .form-item{margin:0 0 5px}#image-annotate-edit-form .form-button,#image-annotate-edit-form .form-submit{margin:0}#image-annotate-edit-form a{background-color:#fff;background-repeat:no-repeat;background-position:3px 3px;border:1px solid #ccc;color:#333;cursor:pointer;display:block;float:left;font-family:Verdana,Sans-Serif;font-size:12px;height:18px;line-height:18px;padding:2px 0 2px 24px;margin:3px 6px 3px 0;width:48px}#image-annotate-edit-form a:hover{background-color:#eee}.image-annotate-edit-area{border:1px solid #000;cursor:move;display:block;height:60px;left:10px;margin:0;padding:0;position:absolute;top:10px;width:60px}.image-annotate-edit-area .ui-resizable-handle{opacity:.8}.image-annotate-edit-ok{background-image:url(../images/accept.png)}.image-annotate-edit-delete{background-image:url(../images/delete.png)}.image-annotate-edit-close{background-image:url(../images/cross.png)}.ui-resizable{position:relative}.ui-resizable-handle{position:absolute;font-size:.1px;z-index:99999;display:block}.ui-resizable- autohide .ui-resizable-handle,.ui-resizable-disabled .ui-resizable-handle{display:block}.ui-resizable-n{cursor:n-resize;height:7px;width:100%;top:-5px;left:0}.ui-resizable-s{cursor:s-resize;height:7px;width:100%;bottom:-5px;left:0}.ui-resizable-e{cursor:e-resize;width:7px;right:-5px;top:0;height:100%}.ui-resizable-w{cursor:w-resize;width:7px;left:-5px;top:0;height:100%}.ui-resizable-se{cursor:se-resize;width:12px;height:12px;right:1px;bottom:1px}.ui-resizable-sw{cursor:sw-resize;width:9px;height:9px;left:-5px;bottom:-5px}.ui-resizable-nw{cursor:nw-resize;width:9px;height:9px;left:-5px;top:-5px}.ui-resizable-ne{cursor:ne-resize;width:9px;height:9px;right:-5px;top:-5px} \ No newline at end of file +.image-annotate-add{--image-annotate-icon-add: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256' fill='%23fff'%3E%3Cpath d='M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z'/%3E%3C/svg%3E");appearance:none;background-color:#0006;background-image:var(--image-annotate-icon-add);background-repeat:no-repeat;background-position:center;background-size:20px 20px;border:1px solid rgba(255,255,255,.5);border-radius:4px;bottom:8px;color:#fff;cursor:pointer;height:32px;opacity:0;padding:0;position:absolute;right:8px;transition:opacity .2s,background-color .2s;width:32px;z-index:1}.image-annotate-add:hover{background-color:#0009}.image-annotate-add:focus{opacity:1;outline:2px solid var(--image-annotate-hover-editable-color);outline-offset:2px}.image-annotate-canvas{--image-annotate-font-family: Verdana, sans-serif;--image-annotate-font-size: 12px;--image-annotate-area-border: #000;--image-annotate-area-inner-border: #fff;--image-annotate-hover-color: yellow;--image-annotate-hover-editable-color: #00ad00;--image-annotate-note-bg: #e7ffe7;--image-annotate-note-border: #397f39;--image-annotate-note-text: #000;--image-annotate-edit-bg: #fffee3;--image-annotate-edit-border: #000;--image-annotate-button-bg: #fff;--image-annotate-button-bg-hover: #eee;--image-annotate-button-border: #ccc;--image-annotate-button-text: #000;--image-annotate-icon-save: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256' fill='%23333'%3E%3Cpath d='M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E");--image-annotate-icon-cancel: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256' fill='%23333'%3E%3Cpath d='M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z'/%3E%3C/svg%3E");--image-annotate-icon-delete: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256' fill='%23333'%3E%3Cpath d='M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z'/%3E%3C/svg%3E");border:solid 1px var(--image-annotate-button-border);display:inline-block;margin:0;max-width:100%;position:relative}.image-annotate-canvas>img{display:block;height:auto;max-width:100%}.image-annotate-view{display:none;inset:0;position:absolute}@media(hover:hover){.image-annotate-canvas:hover:not(.image-annotate-editing) .image-annotate-view{display:block}}@media(hover:none){.image-annotate-canvas:not(.image-annotate-editing) .image-annotate-view{display:block}}@media(hover:hover){.image-annotate-canvas:hover:not(.image-annotate-editing) .image-annotate-add{opacity:1}}@media(hover:none){.image-annotate-canvas:not(.image-annotate-editing) .image-annotate-add{opacity:1}}.image-annotate-editing .image-annotate-add{display:none}.image-annotate-area{border:1px solid var(--image-annotate-area-border);position:absolute}.image-annotate-area div{border:1px solid var(--image-annotate-area-inner-border);box-sizing:border-box;display:block}.image-annotate-area-hover div{border-color:var(--image-annotate-hover-color)}.image-annotate-area-editable{cursor:pointer}.image-annotate-area-editable-hover div{border-color:var(--image-annotate-hover-editable-color)}.image-annotate-note{background-color:var(--image-annotate-note-bg);border:solid 1px var(--image-annotate-note-border);color:var(--image-annotate-note-text);display:none;font-family:var(--image-annotate-font-family);font-size:var(--image-annotate-font-size);left:-1px;max-width:200px;padding:3px 7px;position:absolute;top:calc(100% + 7px)}.image-annotate-note .actions{display:block;font-size:80%}.image-annotate-edit{display:none;inset:0;position:absolute}.image-annotate-edit-form{background-color:var(--image-annotate-edit-bg);border:1px solid var(--image-annotate-edit-border);box-sizing:border-box;cursor:default;display:flex;flex-direction:column;gap:7px;left:-1px;min-width:250px;padding:7px;position:absolute;top:calc(100% + 7px);width:max-content}.image-annotate-edit-form form{margin:0;padding:0}.image-annotate-edit-form textarea{box-sizing:border-box;font-family:var(--image-annotate-font-family);font-size:var(--image-annotate-font-size);height:50px;width:100%}.image-annotate-edit-buttons{display:flex;flex-wrap:nowrap;gap:6px}.image-annotate-edit-form button{appearance:none;background-color:var(--image-annotate-button-bg);background-repeat:no-repeat;background-position:3px center;background-size:16px 16px;border:solid 1px var(--image-annotate-button-border);color:var(--image-annotate-button-text);cursor:pointer;font-family:var(--image-annotate-font-family);font-size:var(--image-annotate-font-size);line-height:18px;min-height:22px;padding:2px 6px 2px 24px;white-space:nowrap}.image-annotate-edit-form button:empty{background-position:center;min-width:24px;padding:2px 4px}.image-annotate-edit-form button:hover{background-color:var(--image-annotate-button-bg-hover)}.image-annotate-edit-area{border:1px solid var(--image-annotate-area-border);cursor:move;display:block;height:60px;left:10px;margin:0;padding:0;position:absolute;top:10px;width:60px}.image-annotate-resize-handle{background-color:var(--image-annotate-area-border);border:1px solid var(--image-annotate-area-inner-border);box-sizing:border-box;height:8px;position:absolute;width:8px}.image-annotate-resize-handle-nw{cursor:nw-resize;left:-4px;top:-4px}.image-annotate-resize-handle-ne{cursor:ne-resize;right:-4px;top:-4px}.image-annotate-resize-handle-sw{cursor:sw-resize;left:-4px;bottom:-4px}.image-annotate-resize-handle-se{cursor:se-resize;right:-4px;bottom:-4px}.image-annotate-edit-ok{background-image:var(--image-annotate-icon-save)}.image-annotate-edit-delete{background-image:var(--image-annotate-icon-delete)}.image-annotate-edit-close{background-image:var(--image-annotate-icon-cancel)}.image-annotate-area-editable:focus{outline:2px solid var(--image-annotate-hover-editable-color);outline-offset:2px}.image-annotate-edit-form button:focus{outline:2px solid var(--image-annotate-hover-editable-color);outline-offset:1px} diff --git a/e2e/jquery-basics.spec.ts b/e2e/jquery-basics.spec.ts index f9cebff..e12e1ae 100644 --- a/e2e/jquery-basics.spec.ts +++ b/e2e/jquery-basics.spec.ts @@ -25,17 +25,16 @@ test.describe('jQuery Basics', () => { test('hover shows tooltip text', async ({ page }) => { const canvas = page.locator('.image-annotate-canvas').first(); - // Areas are prepended, tooltips appended — last area = first tooltip - const lastArea = canvas.locator('.image-annotate-area').last(); - const firstNote = canvas.locator('.image-annotate-note').first(); + const area = canvas.locator('.image-annotate-area').last(); + const tooltip = area.locator('.image-annotate-note'); - await expect(firstNote).toBeHidden(); + await expect(tooltip).toBeHidden(); // Hover canvas to reveal overlay, then hover area for tooltip await canvas.hover(); - await lastArea.hover(); + await area.hover(); - await expect(firstNote).toBeVisible(); + await expect(tooltip).toBeVisible(); }); test('read-only image initializes with canvas', async ({ page }) => { diff --git a/e2e/scaling.spec.ts b/e2e/scaling.spec.ts new file mode 100644 index 0000000..5d34a08 --- /dev/null +++ b/e2e/scaling.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Scaling', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/demo/scaling.html'); + await page.waitForSelector('.image-annotate-canvas', { state: 'attached' }); + }); + + test('CSS-constrained image: canvas is narrower than natural image width', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').first(); + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.width).toBeLessThanOrEqual(500); + expect(box!.width).toBeGreaterThan(0); + }); + + test('CSS-constrained image: renders 4 annotations', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').first(); + const areas = canvas.locator('.image-annotate-area'); + await expect(areas).toHaveCount(4); + }); + + test('CSS-constrained image: annotations are within canvas bounds', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').first(); + const canvasBox = await canvas.boundingBox(); + const areas = canvas.locator('.image-annotate-area'); + const count = await areas.count(); + + for (let i = 0; i < count; i++) { + const areaBox = await areas.nth(i).boundingBox(); + if (areaBox && canvasBox) { + expect(areaBox.x).toBeGreaterThanOrEqual(canvasBox.x - 1); + expect(areaBox.y).toBeGreaterThanOrEqual(canvasBox.y - 1); + expect(areaBox.x + areaBox.width).toBeLessThanOrEqual(canvasBox.x + canvasBox.width + 2); + expect(areaBox.y + areaBox.height).toBeLessThanOrEqual(canvasBox.y + canvasBox.height + 2); + } + } + }); + + test('explicit-size image: canvas matches explicit dimensions', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').nth(1); + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + // Canvas bounding box includes the 1px border on each side + expect(box!.width).toBeCloseTo(402, 0); + }); + + test('responsive image: renders 4 annotations', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').nth(2); + const areas = canvas.locator('.image-annotate-area'); + await expect(areas).toHaveCount(4); + }); + + test('responsive image: canvas resizes with viewport', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').nth(2); + const initialBox = await canvas.boundingBox(); + + // Shrink viewport below the 700px media-query breakpoint so + // .demo-content max-width drops from 1100px to 900px, which + // changes the effective width of the 50% responsive container. + await page.setViewportSize({ width: 600, height: 800 }); + await page.waitForTimeout(300); + + const newBox = await canvas.boundingBox(); + expect(newBox).not.toBeNull(); + if (initialBox && newBox) { + expect(newBox.width).toBeLessThan(initialBox.width); + } + }); +}); diff --git a/e2e/vanilla-basics.spec.ts b/e2e/vanilla-basics.spec.ts index e76045c..11b6f6b 100644 --- a/e2e/vanilla-basics.spec.ts +++ b/e2e/vanilla-basics.spec.ts @@ -26,21 +26,19 @@ test.describe('Vanilla Basics', () => { test('hover shows tooltip text', async ({ page }) => { const canvas = page.locator('.image-annotate-canvas').first(); - // Areas are prepended (insertBefore firstChild) while tooltips are - // appended, so the last area in DOM corresponds to the first tooltip. - const lastArea = canvas.locator('.image-annotate-area').last(); - const firstNote = canvas.locator('.image-annotate-note').first(); + const area = canvas.locator('.image-annotate-area').last(); + const tooltip = area.locator('.image-annotate-note'); // Tooltip should be hidden before hover - await expect(firstNote).toBeHidden(); + await expect(tooltip).toBeHidden(); // Hover over the canvas to reveal the view overlay, // then hover over the annotation area to trigger the tooltip await canvas.hover(); - await lastArea.hover(); + await area.hover(); // Tooltip should now be visible - await expect(firstNote).toBeVisible(); + await expect(tooltip).toBeVisible(); }); test('read-only image initializes with canvas', async ({ page }) => { diff --git a/package-lock.json b/package-lock.json index 954a478..b899fb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jquery-image-annotate", - "version": "2.0.0", + "version": "2.0.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jquery-image-annotate", - "version": "2.0.0", + "version": "2.0.0-beta.1", "license": "GPL-2.0", "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/package.json b/package.json index e24a584..7b4549f 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,16 @@ { "name": "annotate-image", - "version": "2.0.0", + "version": "2.0.0-beta.2", + "description": "Create Flickr-like comment annotations on images — draw rectangles, add notes, save via AJAX or static data", "license": "GPL-2.0", + "repository": { + "type": "git", + "url": "https://github.com/flipbit/jquery-image-annotate.git" + }, "files": [ "dist/", - "LICENSE" + "LICENSE", + "README.md" ], "main": "dist/core.js", "types": "dist/types/index.d.ts", diff --git a/readme.md b/readme.md index b239d28..4eba002 100644 --- a/readme.md +++ b/readme.md @@ -171,6 +171,7 @@ Creates an annotation layer on an image element. Returns an `AnnotateImage` inst | `onDelete` | `(note: NoteData) => void` | — | Called after a note is deleted | | `onLoad` | `(notes: NoteData[]) => void` | — | Called after notes are loaded | | `onError` | `(ctx: AnnotateErrorContext) => void` | — | Called on API errors (defaults to `console.error`) | +| `autoResize` | `boolean` | `true` | Re-scale annotations when the container resizes | #### `AnnotateApi` @@ -228,6 +229,7 @@ type NoteData = Omit; | `onDelete` | `(note: NoteData) => void` | — | Note deleted | | `onLoad` | `(notes: NoteData[]) => void` | — | Notes loaded | | `onError` | `(ctx: AnnotateErrorContext) => void` | — | Error occurred | +| `autoResize` | `boolean` | `true` | Re-scale on container resize | #### Ref Methods (`AnnotateImageRef`) @@ -249,6 +251,7 @@ type NoteData = Omit; | `height` | `Number` | — | Image height in pixels | | `notes` | `AnnotationNote[]` | — | Initial annotations | | `editable` | `Boolean` | `true` | Enable editing | +| `autoResize` | `Boolean` | `true` | Re-scale on container resize | #### Emits @@ -270,6 +273,28 @@ type NoteData = Omit; | `getNotes()` | `NoteData[]` | Get current annotations | | `notes` | `Ref` | Reactive current notes | +## Scaling + +The plugin automatically detects the rendered image size and scales annotations accordingly. Annotation coordinates are always stored in natural (original) image pixels, so the same data works regardless of display size. + +- **CSS constraints** (e.g., `max-width: 500px`) are respected automatically +- **HTML size attributes** (e.g., `width="400"`) work as expected +- **Responsive layouts** are supported via `ResizeObserver` — annotations reposition when the container resizes +- Set `autoResize: false` to disable dynamic resizing (annotations scale once at initialization) + +```js +// Works automatically — no configuration needed +annotate(document.getElementById('myImage'), { + notes: [/* coordinates in natural image pixels */], +}); + +// Disable dynamic resizing +annotate(document.getElementById('myImage'), { + autoResize: false, + notes: [/* ... */], +}); +``` + ## Tree Shaking Each entry point (`annotate-image`, `annotate-image/jquery`, `annotate-image/react`, `annotate-image/vue`) is a separate bundle. Importing one does not pull in the others. Unused framework adapters are excluded automatically by bundlers that support package `exports`. diff --git a/src/annotate-edit.ts b/src/annotate-edit.ts index 68a0451..32d6d87 100644 --- a/src/annotate-edit.ts +++ b/src/annotate-edit.ts @@ -48,10 +48,11 @@ export class AnnotateEdit { // Set area (reuse the existing edit-area element inside the edit overlay) this.area = image.editOverlay.querySelector('.image-annotate-edit-area') as HTMLElement; - this.area.style.height = this.note.height + 'px'; - this.area.style.width = this.note.width + 'px'; - this.area.style.left = this.note.left + 'px'; - this.area.style.top = this.note.top + 'px'; + const rendered = image.toRendered(this.note); + this.area.style.height = rendered.height + 'px'; + this.area.style.width = rendered.width + 'px'; + this.area.style.left = rendered.left + 'px'; + this.area.style.top = rendered.top + 'px'; // Create the form this.form = document.createElement('div'); @@ -155,15 +156,22 @@ export class AnnotateEdit { } this.image.notifySave(stripInternals(this.note)); this.destroy(); + this.image.flushPendingRescale(); }; - // Update note from current area position + // Update note from current area position (convert rendered back to natural) const pos = readInlinePosition(this.area); const size = readInlineSize(this.area); - this.note.top = pos.top; - this.note.left = pos.left; - this.note.width = size.width; - this.note.height = size.height; + const natural = this.image.toNatural({ + top: pos.top, + left: pos.left, + width: size.width, + height: size.height, + }); + this.note.top = natural.top; + this.note.left = natural.left; + this.note.width = natural.width; + this.note.height = natural.height; this.note.text = text; if (this.image.api.save) { @@ -205,6 +213,7 @@ export class AnnotateEdit { const idx = this.image.notes.indexOf(this.note); if (idx !== -1) this.image.notes.splice(idx, 1); this.image.notifyDelete(stripInternals(this.note)); + this.image.flushPendingRescale(); }; if (this.image.api.delete) { diff --git a/src/annotate-image.ts b/src/annotate-image.ts index c46b2bc..a5fda43 100644 --- a/src/annotate-image.ts +++ b/src/annotate-image.ts @@ -78,6 +78,42 @@ export class AnnotateImage { handlers: InteractionHandlers; activeEdit: AnnotateEdit | null = null; private destroyed = false; + private pendingRescale = false; + private resizeObserver?: ResizeObserver; + private originalParent: Node | null = null; + private originalNextSibling: Node | null = null; + /** Natural (intrinsic) image width. */ + readonly naturalWidth: number; + /** Natural (intrinsic) image height. */ + readonly naturalHeight: number; + /** Current horizontal scale factor (rendered / natural). */ + scaleX: number; + /** Current vertical scale factor (rendered / natural). */ + scaleY: number; + + /** Convert a rect from natural image coordinates to rendered (scaled) coordinates. */ + toRendered(rect: { top: number; left: number; width: number; height: number }) { + return { + top: rect.top * this.scaleY, + left: rect.left * this.scaleX, + width: rect.width * this.scaleX, + height: rect.height * this.scaleY, + }; + } + + /** Convert a rect from rendered (scaled) coordinates to natural image coordinates. */ + toNatural(rect: { top: number; left: number; width: number; height: number }) { + const result = { + top: rect.top / this.scaleY, + left: rect.left / this.scaleX, + width: rect.width / this.scaleX, + height: rect.height / this.scaleY, + }; + if (!isFinite(result.top) || !isFinite(result.left) || !isFinite(result.width) || !isFinite(result.height)) { + throw new Error('image-annotate: scale conversion produced non-finite coordinates'); + } + return result; + } /** * @param img - Image element to annotate. Must be in the DOM with non-zero dimensions. @@ -87,14 +123,27 @@ export class AnnotateImage { this.options = options; this.handlers = createDefaultHandlers(); this.img = img; - const width = img.width; - const height = img.height; - if (width === 0 || height === 0) { + + // Read natural and rendered dimensions + this.naturalWidth = img.naturalWidth || img.width; + this.naturalHeight = img.naturalHeight || img.height; + const rendered = img.getBoundingClientRect(); + const renderedWidth = rendered.width || img.width; + const renderedHeight = rendered.height || img.height; + + if (this.naturalWidth === 0 || this.naturalHeight === 0) { throw new Error('image-annotate: image must have non-zero dimensions (is the image loaded?)'); } - this.notes = options.notes.map(n => ({ ...n })); - // Build canvas structure + this.scaleX = renderedWidth / this.naturalWidth; + this.scaleY = renderedHeight / this.naturalHeight; + this.notes = options.notes.map((n) => ({ ...n })); + + // Record original DOM position for destroy restoration + this.originalParent = img.parentNode; + this.originalNextSibling = img.nextSibling; + + // Build canvas structure — wrap the image this.canvas = document.createElement('div'); this.canvas.className = 'image-annotate-canvas'; @@ -108,23 +157,14 @@ export class AnnotateImage { editArea.className = 'image-annotate-edit-area'; this.editOverlay.appendChild(editArea); - this.canvas.appendChild(this.viewOverlay); - this.canvas.appendChild(this.editOverlay); - - // Insert canvas after the image + // Insert canvas at the image's original position, then move image inside if (!img.parentNode) { throw new Error('image-annotate: image must be in the DOM before initialization'); } - img.parentNode.insertBefore(this.canvas, img.nextSibling); - - // Set dimensions and background - this.canvas.style.height = height + 'px'; - this.canvas.style.width = width + 'px'; - this.canvas.style.backgroundImage = 'url("' + img.src + '")'; - this.viewOverlay.style.height = height + 'px'; - this.viewOverlay.style.width = width + 'px'; - this.editOverlay.style.height = height + 'px'; - this.editOverlay.style.width = width + 'px'; + img.parentNode.insertBefore(this.canvas, img); + this.canvas.appendChild(img); + this.canvas.appendChild(this.viewOverlay); + this.canvas.appendChild(this.editOverlay); // Load notes this.api = this.options.api ? normalizeApi(this.options.api) : {}; @@ -139,8 +179,17 @@ export class AnnotateImage { this.createButton(); } - // Hide original image - img.style.display = 'none'; + // Set up ResizeObserver for dynamic resizing + if (options.autoResize !== false && typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const { width, height } = entry.contentRect; + if (width === 0 || height === 0) return; + this.rescale(width, height); + }); + this.resizeObserver.observe(this.canvas); + } } /** Current interaction mode — 'view' for browsing, 'edit' when an annotation is being created or modified. */ @@ -229,11 +278,25 @@ export class AnnotateImage { this.button.remove(); } + // Disconnect ResizeObserver + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = undefined; + } + + // Restore image to its original DOM position. + // Guard against cases where the parent was already removed (e.g. React + // unmount removes the container before effect cleanup runs). + if (this.originalParent && this.originalParent.isConnected) { + // The original next sibling may have moved (e.g. another plugin instance + // wrapped it), so only use it as reference if it's still a child of the + // original parent. + const ref = this.originalNextSibling?.parentNode === this.originalParent ? this.originalNextSibling : null; + this.originalParent.insertBefore(this.img, ref); + } + // Remove canvas from DOM this.canvas.remove(); - - // Restore original image - this.img.style.display = ''; } /** Cancel the active edit (if any) and return to view mode. */ @@ -242,13 +305,47 @@ export class AnnotateImage { this.activeEdit.destroy(); this.setMode('view'); } + this.flushPendingRescale(); + } + + /** Recompute scale factors, deferring if an edit is active. */ + private rescale(renderedWidth: number, renderedHeight: number): void { + if (this.mode === 'edit') { + this.pendingRescale = true; + return; + } + this.applyRescale(renderedWidth, renderedHeight); + } + + /** Apply new scale factors and re-render all views. */ + private applyRescale(renderedWidth: number, renderedHeight: number): void { + const newScaleX = renderedWidth / this.naturalWidth; + const newScaleY = renderedHeight / this.naturalHeight; + + if (newScaleX === this.scaleX && newScaleY === this.scaleY) return; + + this.scaleX = newScaleX; + this.scaleY = newScaleY; + + this.destroyViews(); + this.createViews(); + } + + /** @internal Flush any deferred rescale after an edit completes. */ + flushPendingRescale(): void { + if (!this.pendingRescale) return; + this.pendingRescale = false; + const rect = this.canvas.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + this.applyRescale(rect.width, rect.height); + } } /** Replace all annotations with new data. Does not fire lifecycle callbacks. */ setNotes(notes: AnnotationNote[]): void { if (this.destroyed) return; this.destroyViews(); - this.notes = notes.map(n => ({ ...n })); + this.notes = notes.map((n) => ({ ...n })); this.createViews(); } diff --git a/src/annotate-view.ts b/src/annotate-view.ts index 140532e..3096b9a 100644 --- a/src/annotate-view.ts +++ b/src/annotate-view.ts @@ -73,13 +73,14 @@ export class AnnotateView { } } - /** Apply the note's position and dimensions to the area element. */ + /** Apply the note's position and dimensions to the area element, scaled to rendered size. */ setPosition(): void { + const rendered = this.image.toRendered(this.note); const innerDiv = this.area.firstElementChild as HTMLElement; - innerDiv.style.height = this.note.height + 'px'; - innerDiv.style.width = this.note.width + 'px'; - this.area.style.left = this.note.left + 'px'; - this.area.style.top = this.note.top + 'px'; + innerDiv.style.height = rendered.height + 'px'; + innerDiv.style.width = rendered.width + 'px'; + this.area.style.left = rendered.left + 'px'; + this.area.style.top = rendered.top + 'px'; } /** Update the view's position, size, and text from the edit area after a save. */ @@ -87,21 +88,19 @@ export class AnnotateView { this.tooltip.textContent = text; this.tooltip.style.display = 'none'; - const areaPos = readInlinePosition(editable.area); - const areaSize = readInlineSize(editable.area); - - // Resize inner div + // Position view DOM using the note's natural coordinates (already converted by edit) + const rendered = this.image.toRendered(editable.note); const innerDiv = this.area.firstElementChild as HTMLElement; - innerDiv.style.height = areaSize.height + 'px'; - innerDiv.style.width = areaSize.width + 'px'; - this.area.style.left = areaPos.left + 'px'; - this.area.style.top = areaPos.top + 'px'; - - // Save new position to note - this.note.top = areaPos.top; - this.note.left = areaPos.left; - this.note.height = areaSize.height; - this.note.width = areaSize.width; + innerDiv.style.height = rendered.height + 'px'; + innerDiv.style.width = rendered.width + 'px'; + this.area.style.left = rendered.left + 'px'; + this.area.style.top = rendered.top + 'px'; + + // Copy natural coordinates from the edit note + this.note.top = editable.note.top; + this.note.left = editable.note.left; + this.note.height = editable.note.height; + this.note.width = editable.note.width; this.note.text = text; this.note.id = editable.note.id; this.editable = true; diff --git a/src/annotation.css b/src/annotation.css index 6b6bfb8..5f22dad 100644 --- a/src/annotation.css +++ b/src/annotation.css @@ -50,16 +50,20 @@ --image-annotate-icon-cancel: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256' fill='%23333'%3E%3Cpath d='M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z'/%3E%3C/svg%3E"); --image-annotate-icon-delete: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256' fill='%23333'%3E%3Cpath d='M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z'/%3E%3C/svg%3E"); border: solid 1px var(--image-annotate-button-border); - max-width: 100%; - background-position: left top; - background-repeat: no-repeat; - display: block; + display: inline-block; margin: 0; + max-width: 100%; position: relative; } +.image-annotate-canvas > img { + display: block; + height: auto; + max-width: 100%; +} .image-annotate-view { display: none; - position: relative; + inset: 0; + position: absolute; } @media (hover: hover) { .image-annotate-canvas:hover:not(.image-annotate-editing) .image-annotate-view { @@ -121,6 +125,8 @@ } .image-annotate-edit { display: none; + inset: 0; + position: absolute; } .image-annotate-edit-form { background-color: var(--image-annotate-edit-bg); diff --git a/src/index.ts b/src/index.ts index e9c22b7..45b6b9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export type { AnnotationNote, AnnotateImageOptions, AnnotateApi, AnnotateErrorCo const defaults: AnnotateImageOptions = { editable: true, notes: [], + autoResize: true, labels: { ...DEFAULT_LABELS }, }; diff --git a/src/react.tsx b/src/react.tsx index 57f4f12..124ac44 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -32,6 +32,8 @@ export interface AnnotateImageProps { onLoad?: (notes: NoteData[]) => void; /** Called when an operation fails. */ onError?: (context: AnnotateErrorContext) => void; + /** Enable automatic re-scaling when the container resizes. Default: true. */ + autoResize?: boolean; } /** Imperative methods exposed via ref. */ @@ -78,6 +80,7 @@ export const AnnotateImage = forwardRef( try { const instance = new AnnotateImageCore(imgRef.current, { editable: props.editable ?? true, + autoResize: props.autoResize ?? true, notes: props.notes ? props.notes.slice() : [], onChange: (notes) => onChangeRef.current?.(notes), onSave: (note) => onSaveRef.current?.(note), @@ -119,6 +122,10 @@ export const AnnotateImage = forwardRef( getNotes() { return instanceRef.current?.getNotes() ?? []; }, })); - return {props.alt}; + return ( + + {props.alt} + + ); }, ); diff --git a/src/types.ts b/src/types.ts index 1989b37..903292c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -109,6 +109,9 @@ export interface AnnotateImageOptions { /** UI label overrides. Missing fields use built-in defaults. */ labels?: Labels; + + /** Attach a ResizeObserver to rescale annotations when the image resizes. Default: true. */ + autoResize?: boolean; } export interface DragCallbacks { diff --git a/src/vue.ts b/src/vue.ts index 4903019..62a8434 100644 --- a/src/vue.ts +++ b/src/vue.ts @@ -25,6 +25,8 @@ export const AnnotateImage = defineComponent({ notes: { type: Array as () => AnnotationNote[] }, /** Enable annotation editing. Default: true. */ editable: { type: Boolean, default: true }, + /** Enable automatic re-scaling when the container resizes. Default: true. */ + autoResize: { type: Boolean, default: true }, }, emits: { @@ -46,6 +48,7 @@ export const AnnotateImage = defineComponent({ try { const instance = new AnnotateImageCore(imgRef.value, { editable: props.editable, + autoResize: props.autoResize, notes: props.notes ? props.notes.slice() : [], onChange: (notes) => { currentNotes.value = notes; diff --git a/test/annotate-edit.test.ts b/test/annotate-edit.test.ts index b31edcc..c6449da 100644 --- a/test/annotate-edit.test.ts +++ b/test/annotate-edit.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, vi } from 'vitest'; import '../src/jquery.annotate.ts'; -import { createTestImage, getInstance } from './setup.ts'; +import { createTestImage, getInstance, createScaledTestImage } from './setup.ts'; describe('annotateEdit — creating a new annotation', () => { test('add() switches mode from view to edit', () => { @@ -631,3 +631,60 @@ describe('activeEdit tracking', () => { expect(inst.activeEdit).toBeNull(); }); }); + +describe('auto-scaling — edit positioning', () => { + test('new annotation edit area is scaled by scaleX/scaleY', () => { + const inst = createScaledTestImage(400, 300, 200, 150); + inst.add(); + const area = inst.editOverlay.querySelector('.image-annotate-edit-area') as HTMLElement; + + // DEFAULT_NOTE_LEFT=30 * 0.5 = 15, DEFAULT_NOTE_TOP=30 * 0.5 = 15 + expect(area.style.left).toBe('15px'); + expect(area.style.top).toBe('15px'); + expect(area.style.width).toBe('15px'); + expect(area.style.height).toBe('15px'); + }); + + test('existing annotation edit area is scaled', () => { + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const inst = createScaledTestImage(400, 300, 200, 150); + inst.notes = [{ ...note }]; + inst.load(); + + const view = inst.notes[0].view!; + view.edit(); + + const area = inst.editOverlay.querySelector('.image-annotate-edit-area') as HTMLElement; + expect(area.style.left).toBe('100px'); + expect(area.style.top).toBe('50px'); + expect(area.style.width).toBe('40px'); + expect(area.style.height).toBe('30px'); + }); + + test('save converts rendered coordinates back to natural', () => { + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const inst = createScaledTestImage(400, 300, 200, 150); + inst.notes = [{ ...note }]; + inst.load(); + + const view = inst.notes[0].view!; + view.edit(); + + // Simulate dragging to a new rendered position + const area = inst.editOverlay.querySelector('.image-annotate-edit-area') as HTMLElement; + area.style.left = '50px'; + area.style.top = '25px'; + area.style.width = '40px'; + area.style.height = '30px'; + + // Click save + const saveBtn = inst.canvas.querySelector('.image-annotate-edit-ok') as HTMLElement; + saveBtn.click(); + + // Stored note should be natural coordinates: 50/0.5=100, 25/0.5=50, 40/0.5=80, 30/0.5=60 + expect(inst.notes[0].top).toBe(50); + expect(inst.notes[0].left).toBe(100); + expect(inst.notes[0].width).toBe(80); + expect(inst.notes[0].height).toBe(60); + }); +}); diff --git a/test/annotate-image.test.ts b/test/annotate-image.test.ts index 3988aad..ae39a42 100644 --- a/test/annotate-image.test.ts +++ b/test/annotate-image.test.ts @@ -1,6 +1,6 @@ -import { describe, test, expect, vi } from 'vitest'; +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import '../src/jquery.annotate.ts'; -import { createTestImage, getInstance } from './setup.ts'; +import { createTestImage, getInstance, createScaledTestImage } from './setup.ts'; import type { AnnotateView } from '../src/annotate-view'; describe('annotateImage — initialization', () => { @@ -15,26 +15,18 @@ describe('annotateImage — initialization', () => { expect(inst.canvas.querySelectorAll('.image-annotate-edit-area').length).toBe(1); }); - test('canvas is inserted after the original image', () => { + test('canvas wraps the original image', () => { const image = createTestImage(); const inst = getInstance(image); - const next = image[0].nextElementSibling; - expect(next).toBe(inst.canvas); - expect(next.classList.contains('image-annotate-canvas')).toBe(true); + expect(inst.canvas.contains(image[0])).toBe(true); + expect(image[0].parentElement).toBe(inst.canvas); }); - test('sets canvas background-image from the img src', () => { + test('image is visible inside the canvas', () => { const image = createTestImage(); - const inst = getInstance(image); - - expect(inst.canvas.style.backgroundImage).toContain('test.jpg'); - }); - test('hides the original image', () => { - const image = createTestImage(); - - expect(image[0].style.display).toBe('none'); + expect(image[0].style.display).not.toBe('none'); }); test('view overlay has no inline display style (CSS controls visibility)', () => { @@ -654,3 +646,253 @@ describe('stripInternals', () => { expect('view' in result).toBe(false); }); }); + +describe('auto-scaling — ResizeObserver', () => { + let observeCallback: ((entries: ResizeObserverEntry[]) => void) | null = null; + let disconnected = false; + + beforeEach(() => { + observeCallback = null; + disconnected = false; + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(cb: (entries: ResizeObserverEntry[]) => void) { + observeCallback = cb; + } + observe() {} + disconnect() { + disconnected = true; + } + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('ResizeObserver is attached by default (autoResize defaults to true)', () => { + createScaledTestImage(400, 300, 400, 300); + expect(observeCallback).not.toBeNull(); + }); + + test('ResizeObserver is not attached when autoResize is false', () => { + createScaledTestImage(400, 300, 400, 300, { autoResize: false }); + expect(observeCallback).toBeNull(); + }); + + test('destroy disconnects ResizeObserver', () => { + const inst = createScaledTestImage(400, 300, 400, 300); + inst.destroy(); + expect(disconnected).toBe(true); + }); + + test('resize callback updates scale factors and re-renders views', () => { + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const inst = createScaledTestImage(400, 300, 400, 300, { notes: [note] }); + + expect(inst.scaleX).toBe(1); + + observeCallback!([{ contentRect: { width: 200, height: 150 } }]); + + expect(inst.scaleX).toBe(0.5); + expect(inst.scaleY).toBe(0.5); + + const area = inst.viewOverlay.querySelector('.image-annotate-area') as HTMLElement; + expect(area.style.left).toBe('100px'); + expect(area.style.top).toBe('50px'); + }); + + test('resize is deferred while editing (does not cancel edit)', () => { + const inst = createScaledTestImage(400, 300, 400, 300); + inst.add(); + expect(inst.mode).toBe('edit'); + + observeCallback!([{ contentRect: { width: 200, height: 150 } }]); + + // Edit should still be active + expect(inst.mode).toBe('edit'); + expect(inst.activeEdit).not.toBeNull(); + // Scale should NOT have changed yet + expect(inst.scaleX).toBe(1); + }); + + test('rescale is deferred while in edit mode', () => { + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const inst = createScaledTestImage(400, 300, 400, 300, { notes: [note] }); + expect(inst.scaleX).toBe(1); + + // Enter edit mode + inst.add(); + expect(inst.mode).toBe('edit'); + + // Simulate resize — should be deferred + observeCallback!([{ contentRect: { width: 200, height: 150 } }]); + expect(inst.scaleX).toBe(1); // NOT updated yet + + // Mock canvas getBoundingClientRect so flushPendingRescale can read new size + inst.canvas.getBoundingClientRect = () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 200, + bottom: 150, + width: 200, + height: 150, + toJSON() { + return this; + }, + }); + + // Cancel edit — deferred rescale should now apply + inst.cancelEdit(); + expect(inst.scaleX).toBe(0.5); + expect(inst.scaleY).toBe(0.5); + }); + + test('deferred rescale applies after edit save', () => { + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const inst = createScaledTestImage(400, 300, 400, 300, { notes: [note] }); + + // Click-to-edit the note + const view = inst.notes[0].view!; + view.edit(); + expect(inst.mode).toBe('edit'); + + // Simulate resize while editing + observeCallback!([{ contentRect: { width: 200, height: 150 } }]); + expect(inst.scaleX).toBe(1); // Deferred + + // Mock canvas getBoundingClientRect for flush + inst.canvas.getBoundingClientRect = () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 200, + bottom: 150, + width: 200, + height: 150, + toJSON() { + return this; + }, + }); + + // Save the edit + const saveBtn = inst.canvas.querySelector('.image-annotate-edit-ok') as HTMLElement; + saveBtn.click(); + + // Rescale should have applied + expect(inst.scaleX).toBe(0.5); + }); + + test('no-op rescale with unchanged dimensions does not rebuild views', () => { + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const inst = createScaledTestImage(400, 300, 400, 300, { notes: [note] }); + + // Get reference to original view DOM element + const originalArea = inst.viewOverlay.querySelector('.image-annotate-area'); + + // Fire callback with same dimensions + observeCallback!([{ contentRect: { width: 400, height: 300 } }]); + + // View should NOT have been rebuilt — same DOM reference + const currentArea = inst.viewOverlay.querySelector('.image-annotate-area'); + expect(currentArea).toBe(originalArea); + }); + + test('empty ResizeObserver entries does not crash', () => { + createScaledTestImage(400, 300, 400, 300); + expect(() => observeCallback!([])).not.toThrow(); + }); + + test('zero-dimension entries does not crash or rescale', () => { + const inst = createScaledTestImage(400, 300, 400, 300); + observeCallback!([{ contentRect: { width: 0, height: 0 } }]); + expect(inst.scaleX).toBe(1); + expect(inst.scaleY).toBe(1); + }); +}); + +describe('auto-scaling — scale factor computation', () => { + test('scale factors are 1.0 when rendered size matches natural size', () => { + const inst = createScaledTestImage(400, 300, 400, 300); + expect(inst.scaleX).toBe(1); + expect(inst.scaleY).toBe(1); + }); + + test('scale factors are 0.5 when rendered at half size', () => { + const inst = createScaledTestImage(400, 300, 200, 150); + expect(inst.scaleX).toBe(0.5); + expect(inst.scaleY).toBe(0.5); + }); + + test('non-uniform scaling computes independent X/Y factors', () => { + const inst = createScaledTestImage(400, 300, 200, 300); + expect(inst.scaleX).toBe(0.5); + expect(inst.scaleY).toBe(1); + }); + + test('canvas does not set inline width/height (image provides sizing)', () => { + const inst = createScaledTestImage(400, 300, 200, 150); + expect(inst.canvas.style.width).toBe(''); + expect(inst.canvas.style.height).toBe(''); + }); + + test('canvas does not set background-image (image is visible child)', () => { + const inst = createScaledTestImage(400, 300, 200, 150); + expect(inst.canvas.style.backgroundImage).toBe(''); + }); + + test('naturalWidth and naturalHeight are stored', () => { + const inst = createScaledTestImage(960, 760, 480, 380); + expect(inst.naturalWidth).toBe(960); + expect(inst.naturalHeight).toBe(760); + }); +}); + +describe('toRendered / toNatural coordinate conversion', () => { + test('toRendered scales natural coordinates by scale factors', () => { + const inst = createScaledTestImage(400, 300, 200, 150); + const result = inst.toRendered({ top: 100, left: 200, width: 80, height: 60 }); + expect(result).toEqual({ top: 50, left: 100, width: 40, height: 30 }); + }); + + test('toNatural reverses rendered coordinates to natural', () => { + const inst = createScaledTestImage(400, 300, 200, 150); + const result = inst.toNatural({ top: 50, left: 100, width: 40, height: 30 }); + expect(result).toEqual({ top: 100, left: 200, width: 80, height: 60 }); + }); + + test('toRendered is identity when scale is 1.0', () => { + const inst = createScaledTestImage(400, 300, 400, 300); + const rect = { top: 100, left: 200, width: 80, height: 60 }; + expect(inst.toRendered(rect)).toEqual(rect); + }); + + test('toNatural is identity when scale is 1.0', () => { + const inst = createScaledTestImage(400, 300, 400, 300); + const rect = { top: 100, left: 200, width: 80, height: 60 }; + expect(inst.toNatural(rect)).toEqual(rect); + }); + + test('toNatural throws on non-finite result (defense in depth)', () => { + const inst = createScaledTestImage(400, 300, 200, 150); + // Force scaleX to 0 to trigger guard + inst.scaleX = 0; + expect(() => inst.toNatural({ top: 50, left: 100, width: 40, height: 30 })).toThrow('non-finite coordinates'); + }); + + test('round-trip: toRendered then toNatural returns original values', () => { + const inst = createScaledTestImage(960, 760, 480, 380); + const original = { top: 80, left: 200, width: 100, height: 50 }; + const rendered = inst.toRendered(original); + const restored = inst.toNatural(rendered); + expect(restored.top).toBeCloseTo(original.top); + expect(restored.left).toBeCloseTo(original.left); + expect(restored.width).toBeCloseTo(original.width); + expect(restored.height).toBeCloseTo(original.height); + }); +}); diff --git a/test/annotate-view.test.ts b/test/annotate-view.test.ts index 7783a4d..7d47cc3 100644 --- a/test/annotate-view.test.ts +++ b/test/annotate-view.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest'; import '../src/jquery.annotate.ts'; -import { createTestImage, getInstance } from './setup.ts'; +import { createTestImage, getInstance, createScaledTestImage } from './setup.ts'; +import { AnnotateView } from '../src/annotate-view.ts'; import type { AnnotationNote } from '../src/types.ts'; function createImageWithNote(noteOverrides: Partial = {}) { @@ -280,3 +281,45 @@ describe('annotateView — multiple annotations', () => { expect(inst.viewOverlay.querySelector('.image-annotate-note').textContent).toBe('Keep'); }); }); + +describe('auto-scaling — view positioning', () => { + test('setPosition scales coordinates by scaleX/scaleY', () => { + const inst = createScaledTestImage(400, 300, 200, 150); + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const view = new AnnotateView(inst, note); + + expect(view.area.style.left).toBe('100px'); + expect(view.area.style.top).toBe('50px'); + const inner = view.area.firstElementChild as HTMLElement; + expect(inner.style.width).toBe('40px'); + expect(inner.style.height).toBe('30px'); + }); + + test('at scale 1.0, positioning is unchanged', () => { + const inst = createScaledTestImage(400, 300, 400, 300); + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const view = new AnnotateView(inst, note); + + expect(view.area.style.left).toBe('200px'); + expect(view.area.style.top).toBe('100px'); + }); + + test('resetPosition copies natural coordinates from the edit note', () => { + const inst = createScaledTestImage(400, 300, 200, 150); + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const view = new AnnotateView(inst, note); + + // Simulate what the save handler does: convert rendered area coords to natural on the note + const fakeEditable = { + area: document.createElement('div'), + note: { ...note, top: 50, left: 100, width: 80, height: 60 }, + }; + + view.resetPosition(fakeEditable, 'updated text'); + + expect(view.note.left).toBe(100); + expect(view.note.top).toBe(50); + expect(view.note.width).toBe(80); + expect(view.note.height).toBe(60); + }); +}); diff --git a/test/core-api.test.ts b/test/core-api.test.ts index db21d15..97f86d7 100644 --- a/test/core-api.test.ts +++ b/test/core-api.test.ts @@ -17,11 +17,12 @@ describe('annotate() factory', () => { expect(document.querySelector('.image-annotate-canvas')).not.toBeNull(); }); - test('hides the original image', () => { + test('image is visible inside the canvas', () => { const img = createTestImageVanilla(); - annotate(img); + const inst = annotate(img); - expect(img.style.display).toBe('none'); + expect(img.style.display).not.toBe('none'); + expect(img.parentElement).toBe(inst.canvas); }); test('uses defaults when options are partial', () => { @@ -112,13 +113,13 @@ describe('annotate() factory', () => { }).toThrow('image-annotate: image must have non-zero dimensions (is the image loaded?)'); }); - test('destroy() restores the image', () => { + test('destroy() restores the image to its original position', () => { const img = createTestImageVanilla(); const inst = annotate(img); - expect(img.style.display).toBe('none'); + expect(img.parentElement).toBe(inst.canvas); inst.destroy(); - expect(img.style.display).toBe(''); + expect(img.parentElement).toBe(document.body); expect(document.querySelector('.image-annotate-canvas')).toBeNull(); }); diff --git a/test/destroy.test.ts b/test/destroy.test.ts index 4b1602a..ac8d2f3 100644 --- a/test/destroy.test.ts +++ b/test/destroy.test.ts @@ -12,13 +12,14 @@ describe('destroy()', () => { expect(document.querySelector('.image-annotate-canvas')).toBeNull(); }); - test('restores original image visibility', () => { + test('restores image to original DOM position', () => { const image = createTestImage(); const inst = getInstance(image); - expect(image[0].style.display).toBe('none'); inst.destroy(); - expect(image[0].style.display).toBe(''); + // Image should be back in document.body, not inside the canvas + expect(image[0].parentElement).toBe(document.body); + expect(document.querySelector('.image-annotate-canvas')).toBeNull(); }); test('removes "Add Note" button', () => { diff --git a/test/react.test.tsx b/test/react.test.tsx index 53b89d9..813460c 100644 --- a/test/react.test.tsx +++ b/test/react.test.tsx @@ -239,4 +239,18 @@ describe('React AnnotateImage', () => { expect(onChange).not.toHaveBeenCalled(); }); }); + + describe('autoResize prop', () => { + it('defaults autoResize to true when omitted', () => { + render(); + expect(document.querySelector('.image-annotate-canvas')).not.toBeNull(); + }); + + it('passes autoResize={false} to core', () => { + render( + + ); + expect(document.querySelector('.image-annotate-canvas')).not.toBeNull(); + }); + }); }); diff --git a/test/setup.ts b/test/setup.ts index 9dc0721..9c91725 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,6 +1,6 @@ import jquery from 'jquery'; import { afterEach, vi } from 'vitest'; -import type { AnnotateImage } from '../src/annotate-image.ts'; +import { AnnotateImage } from '../src/annotate-image.ts'; import type { AnnotateImageOptions } from '../src/types.ts'; // Make jQuery available globally so the plugin adapter can find it @@ -49,3 +49,38 @@ export function createTestImageVanilla(): HTMLImageElement { document.body.appendChild(img); return img; } + +/** + * Creates an image with mocked natural/rendered dimensions and returns + * an initialized AnnotateImage instance. Use for testing scale behavior. + */ +export function createScaledTestImage( + naturalW: number, + naturalH: number, + renderedW: number, + renderedH: number, + options: Partial = {}, +): AnnotateImage { + document.body.innerHTML = ''; + const img = document.createElement('img'); + img.src = 'test.jpg'; + img.width = naturalW; + img.height = naturalH; + Object.defineProperty(img, 'naturalWidth', { value: naturalW, configurable: true }); + Object.defineProperty(img, 'naturalHeight', { value: naturalH, configurable: true }); + img.getBoundingClientRect = () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: renderedW, + bottom: renderedH, + width: renderedW, + height: renderedH, + toJSON() { + return this; + }, + }); + document.body.appendChild(img); + return new AnnotateImage(img, { editable: true, notes: [], ...options }); +} diff --git a/test/vue.test.ts b/test/vue.test.ts index a80181e..5722fa5 100644 --- a/test/vue.test.ts +++ b/test/vue.test.ts @@ -242,4 +242,22 @@ describe('Vue AnnotateImage', () => { expect(wrapper.emitted('change') ?? []).toHaveLength(0); }); }); + + describe('autoResize prop', () => { + it('defaults autoResize to true when omitted', () => { + wrapper = mount(AnnotateImage, { + ...mountOpts, + props: { src: 'test.jpg', width: 400, height: 300 }, + }); + expect(document.querySelector('.image-annotate-canvas')).not.toBeNull(); + }); + + it('passes autoResize=false to core', () => { + wrapper = mount(AnnotateImage, { + ...mountOpts, + props: { src: 'test.jpg', width: 400, height: 300, autoResize: false }, + }); + expect(document.querySelector('.image-annotate-canvas')).not.toBeNull(); + }); + }); });