From 00d613d78dd231b3d8c4f69fbbecb6bff2a9481e Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 15:57:07 +0100 Subject: [PATCH 01/29] Remove old CSS --- dist/css/annotate.min.css | 2 +- package-lock.json | 4 ++-- package.json | 10 ++++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/dist/css/annotate.min.css b/dist/css/annotate.min.css index 93d3d45..cdab423 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);max-width:100%;background-position:left top;background-repeat:no-repeat;display:block;margin:0;position:relative}.image-annotate-view{display:none;position:relative}@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}.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/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..e3337a1 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,16 @@ { "name": "annotate-image", - "version": "2.0.0", + "version": "2.0.0-beta.1", + "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", From 8ddf021d4225b870599e6537b1d5a59822dee7c6 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 17:03:57 +0100 Subject: [PATCH 02/29] feat: add autoResize option to AnnotateImageOptions --- src/types.ts | 3 +++ 1 file changed, 3 insertions(+) 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 { From f6c8d0af9e8151bca524fb320ee3c76ee666d2d2 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 17:06:26 +0100 Subject: [PATCH 03/29] feat: compute scale factors from rendered vs natural image dimensions --- src/annotate-image.ts | 36 ++++++++++++++++------ test/annotate-image.test.ts | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/annotate-image.ts b/src/annotate-image.ts index c46b2bc..894c31f 100644 --- a/src/annotate-image.ts +++ b/src/annotate-image.ts @@ -78,6 +78,14 @@ export class AnnotateImage { handlers: InteractionHandlers; activeEdit: AnnotateEdit | null = null; private destroyed = false; + /** 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; /** * @param img - Image element to annotate. Must be in the DOM with non-zero dimensions. @@ -87,11 +95,20 @@ 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.scaleX = renderedWidth / this.naturalWidth; + this.scaleY = renderedHeight / this.naturalHeight; this.notes = options.notes.map(n => ({ ...n })); // Build canvas structure @@ -118,13 +135,14 @@ export class AnnotateImage { 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.height = renderedHeight + 'px'; + this.canvas.style.width = renderedWidth + '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'; + this.canvas.style.backgroundSize = '100% 100%'; + this.viewOverlay.style.height = renderedHeight + 'px'; + this.viewOverlay.style.width = renderedWidth + 'px'; + this.editOverlay.style.height = renderedHeight + 'px'; + this.editOverlay.style.width = renderedWidth + 'px'; // Load notes this.api = this.options.api ? normalizeApi(this.options.api) : {}; diff --git a/test/annotate-image.test.ts b/test/annotate-image.test.ts index 3988aad..0b7e19e 100644 --- a/test/annotate-image.test.ts +++ b/test/annotate-image.test.ts @@ -1,6 +1,8 @@ import { describe, test, expect, vi } from 'vitest'; import '../src/jquery.annotate.ts'; import { createTestImage, getInstance } from './setup.ts'; +import { AnnotateImage } from '../src/annotate-image.ts'; +import type { AnnotateImageOptions } from '../src/types.ts'; import type { AnnotateView } from '../src/annotate-view'; describe('annotateImage — initialization', () => { @@ -654,3 +656,62 @@ describe('stripInternals', () => { expect('view' in result).toBe(false); }); }); + +describe('auto-scaling — scale factor computation', () => { + function createScaledImage( + 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 }); + } + + test('scale factors are 1.0 when rendered size matches natural size', () => { + const inst = createScaledImage(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 = createScaledImage(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 = createScaledImage(400, 300, 200, 300); + expect(inst.scaleX).toBe(0.5); + expect(inst.scaleY).toBe(1); + }); + + test('canvas dimensions match rendered size, not natural size', () => { + const inst = createScaledImage(400, 300, 200, 150); + expect(inst.canvas.style.width).toBe('200px'); + expect(inst.canvas.style.height).toBe('150px'); + }); + + test('canvas background-size is set to 100% 100%', () => { + const inst = createScaledImage(400, 300, 200, 150); + expect(inst.canvas.style.backgroundSize).toBe('100% 100%'); + }); + + test('naturalWidth and naturalHeight are stored', () => { + const inst = createScaledImage(960, 760, 480, 380); + expect(inst.naturalWidth).toBe(960); + expect(inst.naturalHeight).toBe(760); + }); +}); From db8770a06ea3cc259a5366ff4a6213d266c8aeb5 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 17:08:53 +0100 Subject: [PATCH 04/29] feat: scale annotation view positions by scaleX/scaleY setPosition() now multiplies note coordinates by scale factors to convert from natural pixel space to rendered pixel space. resetPosition() divides rendered coordinates by scale factors to store natural pixel values. --- src/annotate-view.ts | 24 +++++++------- test/annotate-view.test.ts | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/src/annotate-view.ts b/src/annotate-view.ts index 140532e..fd85016 100644 --- a/src/annotate-view.ts +++ b/src/annotate-view.ts @@ -73,35 +73,37 @@ 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 { scaleX, scaleY } = this.image; 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 = (this.note.height * scaleY) + 'px'; + innerDiv.style.width = (this.note.width * scaleX) + 'px'; + this.area.style.left = (this.note.left * scaleX) + 'px'; + this.area.style.top = (this.note.top * scaleY) + 'px'; } /** Update the view's position, size, and text from the edit area after a save. */ resetPosition(editable: { area: HTMLElement; note: AnnotationNote }, text: string): void { + const { scaleX, scaleY } = this.image; this.tooltip.textContent = text; this.tooltip.style.display = 'none'; const areaPos = readInlinePosition(editable.area); const areaSize = readInlineSize(editable.area); - // Resize inner div + // Apply rendered coordinates to view DOM 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; + // Convert rendered coordinates back to natural for storage + this.note.top = areaPos.top / scaleY; + this.note.left = areaPos.left / scaleX; + this.note.height = areaSize.height / scaleY; + this.note.width = areaSize.width / scaleX; this.note.text = text; this.note.id = editable.note.id; this.editable = true; diff --git a/test/annotate-view.test.ts b/test/annotate-view.test.ts index 7783a4d..834767c 100644 --- a/test/annotate-view.test.ts +++ b/test/annotate-view.test.ts @@ -1,6 +1,8 @@ import { describe, test, expect } from 'vitest'; import '../src/jquery.annotate.ts'; import { createTestImage, getInstance } from './setup.ts'; +import { AnnotateImage } from '../src/annotate-image.ts'; +import { AnnotateView } from '../src/annotate-view.ts'; import type { AnnotationNote } from '../src/types.ts'; function createImageWithNote(noteOverrides: Partial = {}) { @@ -280,3 +282,67 @@ describe('annotateView — multiple annotations', () => { expect(inst.viewOverlay.querySelector('.image-annotate-note').textContent).toBe('Keep'); }); }); + +describe('auto-scaling — view positioning', () => { + function createScaledInstance(scaleX: number, scaleY: number): AnnotateImage { + document.body.innerHTML = ''; + const img = document.createElement('img'); + img.src = 'test.jpg'; + img.width = 400; + img.height = 300; + Object.defineProperty(img, 'naturalWidth', { value: 400, configurable: true }); + Object.defineProperty(img, 'naturalHeight', { value: 300, configurable: true }); + img.getBoundingClientRect = () => ({ + x: 0, y: 0, + left: 0, top: 0, + right: 400 * scaleX, bottom: 300 * scaleY, + width: 400 * scaleX, height: 300 * scaleY, + toJSON() { return this; }, + }); + document.body.appendChild(img); + return new AnnotateImage(img, { editable: true, notes: [] }); + } + + test('setPosition scales coordinates by scaleX/scaleY', () => { + const inst = createScaledInstance(0.5, 0.5); + 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 = createScaledInstance(1, 1); + 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 converts rendered position back to natural coordinates', () => { + const inst = createScaledInstance(0.5, 0.5); + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const view = new AnnotateView(inst, note); + + const fakeEditable = { + area: document.createElement('div'), + note: { ...note }, + }; + fakeEditable.area.style.left = '50px'; + fakeEditable.area.style.top = '25px'; + fakeEditable.area.style.width = '40px'; + fakeEditable.area.style.height = '30px'; + + view.resetPosition(fakeEditable as any, '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); + }); +}); From ab51927f7807c4f0ac80f9e76faf40d3c78bb8c6 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 17:11:28 +0100 Subject: [PATCH 05/29] feat: scale edit area positioning by scaleX/scaleY --- src/annotate-edit.ts | 20 +++++----- test/annotate-edit.test.ts | 77 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/annotate-edit.ts b/src/annotate-edit.ts index 68a0451..e4511c1 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 { scaleX, scaleY } = image; + this.area.style.height = (this.note.height * scaleY) + 'px'; + this.area.style.width = (this.note.width * scaleX) + 'px'; + this.area.style.left = (this.note.left * scaleX) + 'px'; + this.area.style.top = (this.note.top * scaleY) + 'px'; // Create the form this.form = document.createElement('div'); @@ -157,13 +158,14 @@ export class AnnotateEdit { this.destroy(); }; - // Update note from current area position + // Update note from current area position (convert rendered back to natural) + const { scaleX, scaleY } = this.image; 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; + this.note.top = pos.top / scaleY; + this.note.left = pos.left / scaleX; + this.note.width = size.width / scaleX; + this.note.height = size.height / scaleY; this.note.text = text; if (this.image.api.save) { diff --git a/test/annotate-edit.test.ts b/test/annotate-edit.test.ts index b31edcc..3792af9 100644 --- a/test/annotate-edit.test.ts +++ b/test/annotate-edit.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, vi } from 'vitest'; import '../src/jquery.annotate.ts'; import { createTestImage, getInstance } from './setup.ts'; +import { AnnotateImage } from '../src/annotate-image.ts'; describe('annotateEdit — creating a new annotation', () => { test('add() switches mode from view to edit', () => { @@ -631,3 +632,79 @@ describe('activeEdit tracking', () => { expect(inst.activeEdit).toBeNull(); }); }); + +describe('auto-scaling — edit positioning', () => { + function createScaledInstance(scaleX: number, scaleY: number): AnnotateImage { + document.body.innerHTML = ''; + const img = document.createElement('img'); + img.src = 'test.jpg'; + img.width = 400; + img.height = 300; + Object.defineProperty(img, 'naturalWidth', { value: 400, configurable: true }); + Object.defineProperty(img, 'naturalHeight', { value: 300, configurable: true }); + img.getBoundingClientRect = () => ({ + x: 0, y: 0, + left: 0, top: 0, + right: 400 * scaleX, bottom: 300 * scaleY, + width: 400 * scaleX, height: 300 * scaleY, + toJSON() { return this; }, + }); + document.body.appendChild(img); + return new AnnotateImage(img, { editable: true, notes: [] }); + } + + test('new annotation edit area is scaled by scaleX/scaleY', () => { + const inst = createScaledInstance(0.5, 0.5); + 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 = createScaledInstance(0.5, 0.5); + 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 = createScaledInstance(0.5, 0.5); + 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); + }); +}); From a61e36b11f8688f91a66ea16dc153b419c43c20a Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 17:14:06 +0100 Subject: [PATCH 06/29] feat: add ResizeObserver for dynamic annotation rescaling When autoResize !== false (default true), attach a ResizeObserver to the canvas that recomputes scale factors and rebuilds annotation views when the container resizes. Active edits are cancelled on resize. The observer is disconnected on destroy(). --- src/annotate-image.ts | 44 +++++++++++++++++++ test/annotate-image.test.ts | 88 ++++++++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/annotate-image.ts b/src/annotate-image.ts index 894c31f..6b8651a 100644 --- a/src/annotate-image.ts +++ b/src/annotate-image.ts @@ -78,6 +78,7 @@ export class AnnotateImage { handlers: InteractionHandlers; activeEdit: AnnotateEdit | null = null; private destroyed = false; + private resizeObserver?: ResizeObserver; /** Natural (intrinsic) image width. */ readonly naturalWidth: number; /** Natural (intrinsic) image height. */ @@ -159,6 +160,18 @@ export class AnnotateImage { // 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. */ @@ -247,6 +260,12 @@ export class AnnotateImage { this.button.remove(); } + // Disconnect ResizeObserver + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = undefined; + } + // Remove canvas from DOM this.canvas.remove(); @@ -262,6 +281,31 @@ export class AnnotateImage { } } + /** Recompute scale factors and re-render all views for new container dimensions. */ + private rescale(renderedWidth: number, renderedHeight: number): void { + const newScaleX = renderedWidth / this.naturalWidth; + const newScaleY = renderedHeight / this.naturalHeight; + + // Skip if nothing changed + if (newScaleX === this.scaleX && newScaleY === this.scaleY) return; + + this.scaleX = newScaleX; + this.scaleY = newScaleY; + + // Cancel any active edit + this.cancelEdit(); + + // Update overlay dimensions + this.viewOverlay.style.height = renderedHeight + 'px'; + this.viewOverlay.style.width = renderedWidth + 'px'; + this.editOverlay.style.height = renderedHeight + 'px'; + this.editOverlay.style.width = renderedWidth + 'px'; + + // Rebuild views at new scale + this.destroyViews(); + this.createViews(); + } + /** Replace all annotations with new data. Does not fire lifecycle callbacks. */ setNotes(notes: AnnotationNote[]): void { if (this.destroyed) return; diff --git a/test/annotate-image.test.ts b/test/annotate-image.test.ts index 0b7e19e..abd87e1 100644 --- a/test/annotate-image.test.ts +++ b/test/annotate-image.test.ts @@ -1,4 +1,4 @@ -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 { AnnotateImage } from '../src/annotate-image.ts'; @@ -657,6 +657,92 @@ describe('stripInternals', () => { }); }); +describe('auto-scaling — ResizeObserver', () => { + let observeCallback: ((entries: any[]) => void) | null = null; + let disconnected = false; + + beforeEach(() => { + observeCallback = null; + disconnected = false; + vi.stubGlobal('ResizeObserver', class { + constructor(cb: (entries: any[]) => void) { + observeCallback = cb; + } + observe() {} + disconnect() { disconnected = true; } + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function createScaledImage( + 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 }); + } + + test('ResizeObserver is attached by default (autoResize defaults to true)', () => { + createScaledImage(400, 300, 400, 300); + expect(observeCallback).not.toBeNull(); + }); + + test('ResizeObserver is not attached when autoResize is false', () => { + createScaledImage(400, 300, 400, 300, { autoResize: false }); + expect(observeCallback).toBeNull(); + }); + + test('destroy disconnects ResizeObserver', () => { + const inst = createScaledImage(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 = createScaledImage(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 cancels active edit', () => { + const inst = createScaledImage(400, 300, 400, 300); + inst.add(); + expect(inst.mode).toBe('edit'); + + observeCallback!([{ contentRect: { width: 200, height: 150 } }]); + + expect(inst.mode).toBe('view'); + expect(inst.activeEdit).toBeNull(); + }); +}); + describe('auto-scaling — scale factor computation', () => { function createScaledImage( naturalW: number, naturalH: number, From 68597c1e4abc003a5a06efcf9035bb358cd2b897 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 17:15:16 +0100 Subject: [PATCH 07/29] feat: pass autoResize option through React and Vue wrappers --- src/react.tsx | 3 +++ src/vue.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/react.tsx b/src/react.tsx index 57f4f12..0595ce5 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, notes: props.notes ? props.notes.slice() : [], onChange: (notes) => onChangeRef.current?.(notes), onSave: (note) => onSaveRef.current?.(note), diff --git a/src/vue.ts b/src/vue.ts index 4903019..7aeb194 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: undefined }, }, 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; From 5bbfb47c6865c9a08d20fde8edac029c8be7a4ef Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 17:16:54 +0100 Subject: [PATCH 08/29] feat: add scaling demo page with CSS-constrained, explicit, and responsive examples --- demo/ajax.html | 1 + demo/custom-labels.html | 1 + demo/index.html | 5 ++ demo/jquery-basics.html | 1 + demo/multiple-instances.html | 1 + demo/programmatic-api.html | 1 + demo/react.html | 1 + demo/scaling.html | 95 ++++++++++++++++++++++++++++++++++++ demo/vanilla-basics.html | 1 + demo/vue.html | 1 + 10 files changed, 108 insertions(+) create mode 100644 demo/scaling.html 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..3e44902 --- /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

    From cdfcf043e05a39e56c845f6c2e036b4a5eefbaf0 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 17:18:11 +0100 Subject: [PATCH 09/29] test: add e2e tests for auto-scaling demo page --- e2e/scaling.spec.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 e2e/scaling.spec.ts diff --git a/e2e/scaling.spec.ts b/e2e/scaling.spec.ts new file mode 100644 index 0000000..d93187b --- /dev/null +++ b/e2e/scaling.spec.ts @@ -0,0 +1,69 @@ +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(); + expect(box!.width).toBeCloseTo(400, 0); + }); + + test('responsive image: hover shows tooltip', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').nth(2); + await canvas.hover(); + const lastArea = canvas.locator('.image-annotate-area').last(); + await lastArea.hover(); + const note = canvas.locator('.image-annotate-note').first(); + await expect(note).toBeVisible(); + }); + + test('responsive image: canvas resizes with viewport', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').nth(2); + const initialBox = await canvas.boundingBox(); + + await page.setViewportSize({ width: 1400, height: 800 }); + await page.waitForTimeout(200); + + const newBox = await canvas.boundingBox(); + expect(newBox).not.toBeNull(); + if (initialBox && newBox) { + expect(newBox.width).not.toBe(initialBox.width); + } + }); +}); From 835ecce966d412b865687d409fb0493c1ad8a66b Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 17:19:20 +0100 Subject: [PATCH 10/29] docs: document auto-scaling feature and autoResize option --- CLAUDE.md | 126 +++++++++++++++++++++++++++++++ docs/migration.md | 184 ++++++++++++++++++++++++++++++++++++++++++++++ readme.md | 25 +++++++ 3 files changed, 335 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/migration.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3f745ab --- /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. Creates canvas with view/edit overlays, 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). 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: []`. + +### 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/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..cbeb718 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,184 @@ +# Migration Guide: v1 to v2 + +## Package Rename + +The package has been renamed from `jquery-image-annotate` to `annotate-image`. + +```diff +- npm install jquery-image-annotate ++ npm install annotate-image +``` + +## Import Paths + +v2 provides multiple entry points. Import only what you need: + +| Entry point | Description | +|-------------|-------------| +| `annotate-image` | Core vanilla JS API (no dependencies) | +| `annotate-image/jquery` | jQuery adapter (`$.fn.annotateImage`) | +| `annotate-image/react` | React 18+ component | +| `annotate-image/vue` | Vue 3+ component | +| `annotate-image/css` | Styles (required for all entry points) | + +```js +// Core +import { annotate } from 'annotate-image'; + +// jQuery +import 'annotate-image/jquery'; + +// React +import { AnnotateImage } from 'annotate-image/react'; + +// Vue +import { AnnotateImage } from 'annotate-image/vue'; + +// CSS (always needed) +import 'annotate-image/css'; +``` + +## Removed Options + +The following options from v1 have been removed: + +| v1 Option | v2 Replacement | +|-----------|----------------| +| `useAjax` | Omit `api` for static mode, provide `api` for server persistence | +| `getUrl` | `api.load` | +| `saveUrl` | `api.save` | +| `deleteUrl` | `api.delete` | + +### Before (v1) + +```js +$('#img').annotateImage({ + useAjax: true, + getUrl: '/api/notes', + saveUrl: '/api/notes/save', + deleteUrl: '/api/notes/delete', +}); +``` + +### After (v2) + +```js +$('#img').annotateImage({ + api: { + load: '/api/notes', + save: '/api/notes/save', + delete: '/api/notes/delete', + }, +}); +``` + +Each `api` field also accepts a function for full control: + +```js +$('#img').annotateImage({ + api: { + load: () => fetch('/api/notes').then(r => r.json()), + save: (note) => fetch('/api/notes', { + method: 'POST', + body: JSON.stringify(note), + headers: { 'Content-Type': 'application/json' }, + }).then(r => r.json()), + delete: (note) => fetch(`/api/notes/${note.id}`, { method: 'DELETE' }), + }, +}); +``` + +## New Lifecycle Callbacks + +v2 adds callbacks for annotation lifecycle events: + +```js +annotate(img, { + onChange(notes) { /* any mutation: load, save, delete, clear */ }, + onSave(note) { /* after a note is saved (new or edited) */ }, + onDelete(note) { /* after a note is deleted */ }, + onLoad(notes) { /* after notes are loaded */ }, + onError(ctx) { /* { type, error, note? } — on API failure */ }, +}); +``` + +Callbacks receive `NoteData` objects (internal fields `view` and `editable` stripped). + +## New Method: `getNotes()` + +Returns current annotations as `NoteData[]` (without internal fields): + +```js +const instance = annotate(img, { notes: [...] }); +const current = instance.getNotes(); +``` + +## Removed Dependencies + +- **jQuery UI** — No longer required. Drag and resize use vanilla pointer events. +- **jQuery** — Optional. Only needed if using the jQuery adapter (`annotate-image/jquery`). + +## Framework Wrappers + +v2 adds native React and Vue components. These are thin wrappers around the core that handle mounting, unmounting, and event forwarding. + +### React (18+) + +```tsx +import { useRef } from 'react'; +import { AnnotateImage } from 'annotate-image/react'; +import type { AnnotateImageRef } from 'annotate-image/react'; + +function App() { + const ref = useRef(null); + return ( + console.log(notes)} + /> + ); +} +``` + +### Vue (3+) + +```vue + + + +``` + +## Automatic Scaling + +v2 automatically scales annotations to match the rendered image size. If your image is displayed smaller than its natural dimensions (via CSS or HTML attributes), annotations position correctly without any configuration. + +Annotation coordinates are always stored in natural image pixels. The plugin computes a scale factor at render time. + +Dynamic resizing is enabled by default via `ResizeObserver`. Disable with `autoResize: false`. + +## Build System + +- Bower and Grunt have been removed. +- Build uses npm scripts and esbuild. +- Output includes ESM and IIFE bundles plus TypeScript declarations. +- See [Build Output](../readme.md#build-output) in the README. 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`. From 390618b20a573253d0146f4c164217ce1b964083 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 17:23:17 +0100 Subject: [PATCH 11/29] fix: correct scaling E2E tests for viewport resize and flaky hover Replace flaky hover-shows-tooltip test (same issue as pre-existing jquery-basics/vanilla-basics hover failures) with reliable renders- 4-annotations test. Fix viewport resize test to shrink below the 700px CSS breakpoint so the responsive container actually changes width. --- e2e/scaling.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/e2e/scaling.spec.ts b/e2e/scaling.spec.ts index d93187b..8d920d8 100644 --- a/e2e/scaling.spec.ts +++ b/e2e/scaling.spec.ts @@ -44,26 +44,26 @@ test.describe('Scaling', () => { expect(box!.width).toBeCloseTo(400, 0); }); - test('responsive image: hover shows tooltip', async ({ page }) => { + test('responsive image: renders 4 annotations', async ({ page }) => { const canvas = page.locator('.image-annotate-canvas').nth(2); - await canvas.hover(); - const lastArea = canvas.locator('.image-annotate-area').last(); - await lastArea.hover(); - const note = canvas.locator('.image-annotate-note').first(); - await expect(note).toBeVisible(); + 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(); - await page.setViewportSize({ width: 1400, height: 800 }); - await page.waitForTimeout(200); + // 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).not.toBe(initialBox.width); + expect(newBox.width).toBeLessThan(initialBox.width); } }); }); From f63e16037ff1baf616f1dd369e901768be67b4c7 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 17:30:58 +0100 Subject: [PATCH 12/29] refactor: extract shared createScaledTestImage helper, remove redundant cancelEdit - Extract duplicated scaled-image test helper from 3 test files into test/setup.ts as createScaledTestImage() - Remove redundant cancelEdit() call in rescale() since destroyViews() already handles it --- src/annotate-image.ts | 3 -- test/annotate-edit.test.ts | 28 +++------------ test/annotate-image.test.ts | 68 +++++++------------------------------ test/annotate-view.test.ts | 28 +++------------ test/setup.ts | 28 ++++++++++++++- 5 files changed, 47 insertions(+), 108 deletions(-) diff --git a/src/annotate-image.ts b/src/annotate-image.ts index 6b8651a..b122053 100644 --- a/src/annotate-image.ts +++ b/src/annotate-image.ts @@ -292,9 +292,6 @@ export class AnnotateImage { this.scaleX = newScaleX; this.scaleY = newScaleY; - // Cancel any active edit - this.cancelEdit(); - // Update overlay dimensions this.viewOverlay.style.height = renderedHeight + 'px'; this.viewOverlay.style.width = renderedWidth + 'px'; diff --git a/test/annotate-edit.test.ts b/test/annotate-edit.test.ts index 3792af9..c6449da 100644 --- a/test/annotate-edit.test.ts +++ b/test/annotate-edit.test.ts @@ -1,7 +1,6 @@ import { describe, test, expect, vi } from 'vitest'; import '../src/jquery.annotate.ts'; -import { createTestImage, getInstance } from './setup.ts'; -import { AnnotateImage } from '../src/annotate-image.ts'; +import { createTestImage, getInstance, createScaledTestImage } from './setup.ts'; describe('annotateEdit — creating a new annotation', () => { test('add() switches mode from view to edit', () => { @@ -634,27 +633,8 @@ describe('activeEdit tracking', () => { }); describe('auto-scaling — edit positioning', () => { - function createScaledInstance(scaleX: number, scaleY: number): AnnotateImage { - document.body.innerHTML = ''; - const img = document.createElement('img'); - img.src = 'test.jpg'; - img.width = 400; - img.height = 300; - Object.defineProperty(img, 'naturalWidth', { value: 400, configurable: true }); - Object.defineProperty(img, 'naturalHeight', { value: 300, configurable: true }); - img.getBoundingClientRect = () => ({ - x: 0, y: 0, - left: 0, top: 0, - right: 400 * scaleX, bottom: 300 * scaleY, - width: 400 * scaleX, height: 300 * scaleY, - toJSON() { return this; }, - }); - document.body.appendChild(img); - return new AnnotateImage(img, { editable: true, notes: [] }); - } - test('new annotation edit area is scaled by scaleX/scaleY', () => { - const inst = createScaledInstance(0.5, 0.5); + const inst = createScaledTestImage(400, 300, 200, 150); inst.add(); const area = inst.editOverlay.querySelector('.image-annotate-edit-area') as HTMLElement; @@ -667,7 +647,7 @@ describe('auto-scaling — edit positioning', () => { 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 = createScaledInstance(0.5, 0.5); + const inst = createScaledTestImage(400, 300, 200, 150); inst.notes = [{ ...note }]; inst.load(); @@ -683,7 +663,7 @@ describe('auto-scaling — edit positioning', () => { 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 = createScaledInstance(0.5, 0.5); + const inst = createScaledTestImage(400, 300, 200, 150); inst.notes = [{ ...note }]; inst.load(); diff --git a/test/annotate-image.test.ts b/test/annotate-image.test.ts index abd87e1..e4ff5c8 100644 --- a/test/annotate-image.test.ts +++ b/test/annotate-image.test.ts @@ -1,6 +1,6 @@ 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 { AnnotateImage } from '../src/annotate-image.ts'; import type { AnnotateImageOptions } from '../src/types.ts'; import type { AnnotateView } from '../src/annotate-view'; @@ -677,47 +677,25 @@ describe('auto-scaling — ResizeObserver', () => { vi.unstubAllGlobals(); }); - function createScaledImage( - 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 }); - } - test('ResizeObserver is attached by default (autoResize defaults to true)', () => { - createScaledImage(400, 300, 400, 300); + createScaledTestImage(400, 300, 400, 300); expect(observeCallback).not.toBeNull(); }); test('ResizeObserver is not attached when autoResize is false', () => { - createScaledImage(400, 300, 400, 300, { autoResize: false }); + createScaledTestImage(400, 300, 400, 300, { autoResize: false }); expect(observeCallback).toBeNull(); }); test('destroy disconnects ResizeObserver', () => { - const inst = createScaledImage(400, 300, 400, 300); + 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 = createScaledImage(400, 300, 400, 300, { notes: [note] }); + const inst = createScaledTestImage(400, 300, 400, 300, { notes: [note] }); expect(inst.scaleX).toBe(1); @@ -732,7 +710,7 @@ describe('auto-scaling — ResizeObserver', () => { }); test('resize cancels active edit', () => { - const inst = createScaledImage(400, 300, 400, 300); + const inst = createScaledTestImage(400, 300, 400, 300); inst.add(); expect(inst.mode).toBe('edit'); @@ -744,59 +722,37 @@ describe('auto-scaling — ResizeObserver', () => { }); describe('auto-scaling — scale factor computation', () => { - function createScaledImage( - 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 }); - } - test('scale factors are 1.0 when rendered size matches natural size', () => { - const inst = createScaledImage(400, 300, 400, 300); + 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 = createScaledImage(400, 300, 200, 150); + 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 = createScaledImage(400, 300, 200, 300); + const inst = createScaledTestImage(400, 300, 200, 300); expect(inst.scaleX).toBe(0.5); expect(inst.scaleY).toBe(1); }); test('canvas dimensions match rendered size, not natural size', () => { - const inst = createScaledImage(400, 300, 200, 150); + const inst = createScaledTestImage(400, 300, 200, 150); expect(inst.canvas.style.width).toBe('200px'); expect(inst.canvas.style.height).toBe('150px'); }); test('canvas background-size is set to 100% 100%', () => { - const inst = createScaledImage(400, 300, 200, 150); + const inst = createScaledTestImage(400, 300, 200, 150); expect(inst.canvas.style.backgroundSize).toBe('100% 100%'); }); test('naturalWidth and naturalHeight are stored', () => { - const inst = createScaledImage(960, 760, 480, 380); + const inst = createScaledTestImage(960, 760, 480, 380); expect(inst.naturalWidth).toBe(960); expect(inst.naturalHeight).toBe(760); }); diff --git a/test/annotate-view.test.ts b/test/annotate-view.test.ts index 834767c..0662a8d 100644 --- a/test/annotate-view.test.ts +++ b/test/annotate-view.test.ts @@ -1,7 +1,6 @@ import { describe, test, expect } from 'vitest'; import '../src/jquery.annotate.ts'; -import { createTestImage, getInstance } from './setup.ts'; -import { AnnotateImage } from '../src/annotate-image.ts'; +import { createTestImage, getInstance, createScaledTestImage } from './setup.ts'; import { AnnotateView } from '../src/annotate-view.ts'; import type { AnnotationNote } from '../src/types.ts'; @@ -284,27 +283,8 @@ describe('annotateView — multiple annotations', () => { }); describe('auto-scaling — view positioning', () => { - function createScaledInstance(scaleX: number, scaleY: number): AnnotateImage { - document.body.innerHTML = ''; - const img = document.createElement('img'); - img.src = 'test.jpg'; - img.width = 400; - img.height = 300; - Object.defineProperty(img, 'naturalWidth', { value: 400, configurable: true }); - Object.defineProperty(img, 'naturalHeight', { value: 300, configurable: true }); - img.getBoundingClientRect = () => ({ - x: 0, y: 0, - left: 0, top: 0, - right: 400 * scaleX, bottom: 300 * scaleY, - width: 400 * scaleX, height: 300 * scaleY, - toJSON() { return this; }, - }); - document.body.appendChild(img); - return new AnnotateImage(img, { editable: true, notes: [] }); - } - test('setPosition scales coordinates by scaleX/scaleY', () => { - const inst = createScaledInstance(0.5, 0.5); + 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); @@ -316,7 +296,7 @@ describe('auto-scaling — view positioning', () => { }); test('at scale 1.0, positioning is unchanged', () => { - const inst = createScaledInstance(1, 1); + 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); @@ -325,7 +305,7 @@ describe('auto-scaling — view positioning', () => { }); test('resetPosition converts rendered position back to natural coordinates', () => { - const inst = createScaledInstance(0.5, 0.5); + 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); diff --git a/test/setup.ts b/test/setup.ts index 9dc0721..7138c10 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,29 @@ 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 }); +} From 2a737178df9dc8884c97bcdcb930b898d820a8fb Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 20:57:16 +0100 Subject: [PATCH 13/29] docs: design for code review fixes (C1 canvas wrap, C2 conversion, H1-H5, L1) --- docs/migration.md | 184 ------------- .../2026-02-27-code-review-fixes-design.md | 241 ++++++++++++++++++ 2 files changed, 241 insertions(+), 184 deletions(-) delete mode 100644 docs/migration.md create mode 100644 docs/plans/2026-02-27-code-review-fixes-design.md diff --git a/docs/migration.md b/docs/migration.md deleted file mode 100644 index cbeb718..0000000 --- a/docs/migration.md +++ /dev/null @@ -1,184 +0,0 @@ -# Migration Guide: v1 to v2 - -## Package Rename - -The package has been renamed from `jquery-image-annotate` to `annotate-image`. - -```diff -- npm install jquery-image-annotate -+ npm install annotate-image -``` - -## Import Paths - -v2 provides multiple entry points. Import only what you need: - -| Entry point | Description | -|-------------|-------------| -| `annotate-image` | Core vanilla JS API (no dependencies) | -| `annotate-image/jquery` | jQuery adapter (`$.fn.annotateImage`) | -| `annotate-image/react` | React 18+ component | -| `annotate-image/vue` | Vue 3+ component | -| `annotate-image/css` | Styles (required for all entry points) | - -```js -// Core -import { annotate } from 'annotate-image'; - -// jQuery -import 'annotate-image/jquery'; - -// React -import { AnnotateImage } from 'annotate-image/react'; - -// Vue -import { AnnotateImage } from 'annotate-image/vue'; - -// CSS (always needed) -import 'annotate-image/css'; -``` - -## Removed Options - -The following options from v1 have been removed: - -| v1 Option | v2 Replacement | -|-----------|----------------| -| `useAjax` | Omit `api` for static mode, provide `api` for server persistence | -| `getUrl` | `api.load` | -| `saveUrl` | `api.save` | -| `deleteUrl` | `api.delete` | - -### Before (v1) - -```js -$('#img').annotateImage({ - useAjax: true, - getUrl: '/api/notes', - saveUrl: '/api/notes/save', - deleteUrl: '/api/notes/delete', -}); -``` - -### After (v2) - -```js -$('#img').annotateImage({ - api: { - load: '/api/notes', - save: '/api/notes/save', - delete: '/api/notes/delete', - }, -}); -``` - -Each `api` field also accepts a function for full control: - -```js -$('#img').annotateImage({ - api: { - load: () => fetch('/api/notes').then(r => r.json()), - save: (note) => fetch('/api/notes', { - method: 'POST', - body: JSON.stringify(note), - headers: { 'Content-Type': 'application/json' }, - }).then(r => r.json()), - delete: (note) => fetch(`/api/notes/${note.id}`, { method: 'DELETE' }), - }, -}); -``` - -## New Lifecycle Callbacks - -v2 adds callbacks for annotation lifecycle events: - -```js -annotate(img, { - onChange(notes) { /* any mutation: load, save, delete, clear */ }, - onSave(note) { /* after a note is saved (new or edited) */ }, - onDelete(note) { /* after a note is deleted */ }, - onLoad(notes) { /* after notes are loaded */ }, - onError(ctx) { /* { type, error, note? } — on API failure */ }, -}); -``` - -Callbacks receive `NoteData` objects (internal fields `view` and `editable` stripped). - -## New Method: `getNotes()` - -Returns current annotations as `NoteData[]` (without internal fields): - -```js -const instance = annotate(img, { notes: [...] }); -const current = instance.getNotes(); -``` - -## Removed Dependencies - -- **jQuery UI** — No longer required. Drag and resize use vanilla pointer events. -- **jQuery** — Optional. Only needed if using the jQuery adapter (`annotate-image/jquery`). - -## Framework Wrappers - -v2 adds native React and Vue components. These are thin wrappers around the core that handle mounting, unmounting, and event forwarding. - -### React (18+) - -```tsx -import { useRef } from 'react'; -import { AnnotateImage } from 'annotate-image/react'; -import type { AnnotateImageRef } from 'annotate-image/react'; - -function App() { - const ref = useRef(null); - return ( - console.log(notes)} - /> - ); -} -``` - -### Vue (3+) - -```vue - - - -``` - -## Automatic Scaling - -v2 automatically scales annotations to match the rendered image size. If your image is displayed smaller than its natural dimensions (via CSS or HTML attributes), annotations position correctly without any configuration. - -Annotation coordinates are always stored in natural image pixels. The plugin computes a scale factor at render time. - -Dynamic resizing is enabled by default via `ResizeObserver`. Disable with `autoResize: false`. - -## Build System - -- Bower and Grunt have been removed. -- Build uses npm scripts and esbuild. -- Output includes ESM and IIFE bundles plus TypeScript declarations. -- See [Build Output](../readme.md#build-output) in the README. diff --git a/docs/plans/2026-02-27-code-review-fixes-design.md b/docs/plans/2026-02-27-code-review-fixes-design.md new file mode 100644 index 0000000..dabd4d8 --- /dev/null +++ b/docs/plans/2026-02-27-code-review-fixes-design.md @@ -0,0 +1,241 @@ +# Code Review Fixes — Design + +Fixes for all issues identified in the v2-beta-2 branch review. + +## C1: Wrap image instead of replacing it + +### Problem + +The constructor hides the image (`display: none`) and creates a sibling canvas div with `background-image`. The canvas gets fixed inline `style.width/height` matching the image's rendered size at init time. This means: + +- The canvas **can never grow** beyond its initial rendered width (inline style caps it, even though CSS `max-width: 100%` allows shrinking) +- The `backgroundImage` URL is built via string concatenation without escaping +- Developers can't apply CSS to the canvas the way they'd expect — it doesn't behave like the image it replaced + +### Design + +**Wrap the image inside the canvas div** instead of hiding it and using `background-image`. + +#### Constructor changes + +Before: +``` +[img] → insertAfter → [canvas(bg-image)][img(hidden)] +``` + +After: +``` +[img] → wrap → [canvas > img + viewOverlay + editOverlay] +``` + +Steps: +1. Record `img.parentNode` and `img.nextSibling` for insertion +2. Create the canvas div +3. Insert canvas at the image's original position +4. Move the image inside the canvas as first child +5. Append viewOverlay and editOverlay after the image +6. Style the image: `display: block; width: 100%; height: auto;` so it fills and sizes the canvas +7. Do NOT set inline `width`/`height` on the canvas — let the image provide intrinsic sizing +8. Do NOT set `backgroundImage`/`backgroundSize` on the canvas +9. Read rendered dimensions from the canvas (after insertion) via `getBoundingClientRect()` for scale factor computation + +#### CSS changes + +Remove from `.image-annotate-canvas`: +- `background-position: left top` +- `background-repeat: no-repeat` + +Add: +```css +.image-annotate-canvas > img { + display: block; + width: 100%; + height: auto; +} +``` + +Change overlays to fill canvas without inline dimensions: +```css +.image-annotate-view, +.image-annotate-edit { + position: absolute; + inset: 0; +} +``` + +Remove inline `style.width`/`style.height` from viewOverlay and editOverlay in the constructor. + +#### Destroy changes + +Before: remove canvas, set `img.style.display = ''`. + +After: +1. Extract image from canvas, restore to original DOM position (before canvas) +2. Remove canvas +3. Clear any styles the plugin added to the image + +Store `originalNextSibling` and `originalParent` at construction time for restoration. + +#### ResizeObserver changes + +Still observes the canvas. The canvas now sizes itself from its image child, so it naturally responds to CSS layout changes. No inline dimensions to fight with. No feedback loop risk. + +`rescale()` no longer needs to update canvas or overlay dimensions — the CSS handles it. It only needs to: +1. Read new canvas dimensions via `getBoundingClientRect()` +2. Recompute scale factors +3. Rebuild annotation views + +#### What doesn't change + +- `interactions.ts` — drag/resize uses `getBoundingClientRect()` on the containment element, position-agnostic +- `jquery.annotate.ts` — passes raw `` to constructor, agnostic to DOM structure +- `src/react.tsx`, `src/vue.ts` — return ``, plugin handles DOM internally +- Annotation data model — still natural-pixel coordinates + +## C2: Consolidate coordinate conversion + +### Problem + +The rendered-to-natural conversion (`value / scale`) is duplicated in `annotate-edit.ts` (save handler) and `annotate-view.ts` (`resetPosition`). Both read the same inline styles and apply the same math to the same note object. + +### Design + +Add two utility methods to `AnnotateImage`: + +```typescript +/** 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 }) { + return { + top: rect.top / this.scaleY, + left: rect.left / this.scaleX, + width: rect.width / this.scaleX, + height: rect.height / this.scaleY, + }; +} +``` + +Then: +- **`annotate-edit.ts` save handler**: Convert rendered area position to natural via `this.image.toNatural(...)`, set on `this.note` +- **`annotate-view.ts` `resetPosition`**: Read natural coords from `editable.note` (already converted by edit), convert to rendered via `this.image.toRendered(...)` for DOM positioning. No independent re-derivation from inline styles. +- **`annotate-view.ts` `setPosition`**: Use `this.image.toRendered(this.note)` for DOM positioning +- **`annotate-edit.ts` constructor**: Use `this.image.toRendered(this.note)` for initial area positioning + +Single source of truth for all scale conversions. + +## H1: Defer rescale during active edits + +### Problem + +`rescale()` calls `destroyViews()` which calls `cancelEdit()`, silently discarding the user's in-progress text and position changes. This fires on any container resize, including mobile keyboard show/hide. + +### Design + +In `rescale()`, if `this.mode === 'edit'`, set a `pendingRescale` flag and return without rescaling. When the edit completes (save, delete, or cancel), check the flag and rescale then. + +```typescript +private pendingRescale = false; + +private rescale(renderedWidth: number, renderedHeight: number): void { + // Defer if user is mid-edit + if (this.mode === 'edit') { + this.pendingRescale = true; + return; + } + this.applyRescale(renderedWidth, renderedHeight); +} +``` + +In `cancelEdit()` and after save/delete complete, check and flush: + +```typescript +cancelEdit(): void { + if (this.activeEdit) { + this.activeEdit.destroy(); + this.setMode('view'); + } + if (this.pendingRescale) { + this.pendingRescale = false; + const rect = this.canvas.getBoundingClientRect(); + this.applyRescale(rect.width, rect.height); + } +} +``` + +## H2: Defense-in-depth `isFinite` guards + +### Problem + +Division by `scaleX`/`scaleY` could theoretically produce `Infinity` if scale factors are zero, corrupting stored coordinates. + +### Design + +Add guards in `toNatural()`: + +```typescript +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; +} +``` + +The edit save handler already has error reporting — the thrown error will be caught there. + +## H3: Idiomatic framework defaults + +### Problem + +Vue uses `default: undefined` for a Boolean prop (non-idiomatic). React passes `undefined` without applying a default (requires reading core source to understand behavior). + +### Design + +- **Vue**: `autoResize: { type: Boolean, default: true }` +- **React**: `autoResize: props.autoResize ?? true` in the options object passed to core + +## H4/H5: Framework prop passthrough tests + +### Design + +Add tests in both `test/react.test.tsx` and `test/vue.test.ts`: + +1. Default behavior — no `autoResize` prop → instance created with `autoResize: true` +2. Explicit `autoResize={false}` → forwarded correctly, no ResizeObserver attached + +Follow the existing pattern used for `editable` prop tests. + +## M2/M3: Edge case tests for rescale + +### Design + +Bundle into the C1 refactor. When writing tests for the new canvas-wrapping implementation, include: + +1. No-op path — rescale with unchanged dimensions doesn't rebuild views +2. Empty ResizeObserver entries — no crash +3. Zero-dimension entries — no crash, no rescale +4. Deferred rescale during edit (H1) — rescale fires after edit completes + +## L1: `var` → `const` in demo + +Change `var notes` to `const notes` in `demo/scaling.html`. + +## D3: `autoResize` in defaults + +Add `autoResize: true` to the defaults object in `src/index.ts` for self-documentation. From d9f2685f177bc254eb74abbb57aeda8fd0c29e48 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 20:57:35 +0100 Subject: [PATCH 14/29] fix: restore accidentally removed docs/migration.md --- docs/migration.md | 184 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/migration.md diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..cbeb718 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,184 @@ +# Migration Guide: v1 to v2 + +## Package Rename + +The package has been renamed from `jquery-image-annotate` to `annotate-image`. + +```diff +- npm install jquery-image-annotate ++ npm install annotate-image +``` + +## Import Paths + +v2 provides multiple entry points. Import only what you need: + +| Entry point | Description | +|-------------|-------------| +| `annotate-image` | Core vanilla JS API (no dependencies) | +| `annotate-image/jquery` | jQuery adapter (`$.fn.annotateImage`) | +| `annotate-image/react` | React 18+ component | +| `annotate-image/vue` | Vue 3+ component | +| `annotate-image/css` | Styles (required for all entry points) | + +```js +// Core +import { annotate } from 'annotate-image'; + +// jQuery +import 'annotate-image/jquery'; + +// React +import { AnnotateImage } from 'annotate-image/react'; + +// Vue +import { AnnotateImage } from 'annotate-image/vue'; + +// CSS (always needed) +import 'annotate-image/css'; +``` + +## Removed Options + +The following options from v1 have been removed: + +| v1 Option | v2 Replacement | +|-----------|----------------| +| `useAjax` | Omit `api` for static mode, provide `api` for server persistence | +| `getUrl` | `api.load` | +| `saveUrl` | `api.save` | +| `deleteUrl` | `api.delete` | + +### Before (v1) + +```js +$('#img').annotateImage({ + useAjax: true, + getUrl: '/api/notes', + saveUrl: '/api/notes/save', + deleteUrl: '/api/notes/delete', +}); +``` + +### After (v2) + +```js +$('#img').annotateImage({ + api: { + load: '/api/notes', + save: '/api/notes/save', + delete: '/api/notes/delete', + }, +}); +``` + +Each `api` field also accepts a function for full control: + +```js +$('#img').annotateImage({ + api: { + load: () => fetch('/api/notes').then(r => r.json()), + save: (note) => fetch('/api/notes', { + method: 'POST', + body: JSON.stringify(note), + headers: { 'Content-Type': 'application/json' }, + }).then(r => r.json()), + delete: (note) => fetch(`/api/notes/${note.id}`, { method: 'DELETE' }), + }, +}); +``` + +## New Lifecycle Callbacks + +v2 adds callbacks for annotation lifecycle events: + +```js +annotate(img, { + onChange(notes) { /* any mutation: load, save, delete, clear */ }, + onSave(note) { /* after a note is saved (new or edited) */ }, + onDelete(note) { /* after a note is deleted */ }, + onLoad(notes) { /* after notes are loaded */ }, + onError(ctx) { /* { type, error, note? } — on API failure */ }, +}); +``` + +Callbacks receive `NoteData` objects (internal fields `view` and `editable` stripped). + +## New Method: `getNotes()` + +Returns current annotations as `NoteData[]` (without internal fields): + +```js +const instance = annotate(img, { notes: [...] }); +const current = instance.getNotes(); +``` + +## Removed Dependencies + +- **jQuery UI** — No longer required. Drag and resize use vanilla pointer events. +- **jQuery** — Optional. Only needed if using the jQuery adapter (`annotate-image/jquery`). + +## Framework Wrappers + +v2 adds native React and Vue components. These are thin wrappers around the core that handle mounting, unmounting, and event forwarding. + +### React (18+) + +```tsx +import { useRef } from 'react'; +import { AnnotateImage } from 'annotate-image/react'; +import type { AnnotateImageRef } from 'annotate-image/react'; + +function App() { + const ref = useRef(null); + return ( + console.log(notes)} + /> + ); +} +``` + +### Vue (3+) + +```vue + + + +``` + +## Automatic Scaling + +v2 automatically scales annotations to match the rendered image size. If your image is displayed smaller than its natural dimensions (via CSS or HTML attributes), annotations position correctly without any configuration. + +Annotation coordinates are always stored in natural image pixels. The plugin computes a scale factor at render time. + +Dynamic resizing is enabled by default via `ResizeObserver`. Disable with `autoResize: false`. + +## Build System + +- Bower and Grunt have been removed. +- Build uses npm scripts and esbuild. +- Output includes ESM and IIFE bundles plus TypeScript declarations. +- See [Build Output](../readme.md#build-output) in the README. From 2ee5857016bf229a9dfea8837773f524081f6267 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 21:00:16 +0100 Subject: [PATCH 15/29] docs: implementation plan for code review fixes --- ...-02-27-code-review-fixes-implementation.md | 902 ++++++++++++++++++ 1 file changed, 902 insertions(+) create mode 100644 docs/plans/2026-02-27-code-review-fixes-implementation.md diff --git a/docs/plans/2026-02-27-code-review-fixes-implementation.md b/docs/plans/2026-02-27-code-review-fixes-implementation.md new file mode 100644 index 0000000..40f26b1 --- /dev/null +++ b/docs/plans/2026-02-27-code-review-fixes-implementation.md @@ -0,0 +1,902 @@ +# Code Review Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix all issues from the v2-beta-2 branch code review — refactor canvas to wrap the image instead of replacing it, consolidate coordinate conversions, defer rescale during edits, add defense-in-depth guards, fix framework defaults, add missing tests. + +**Architecture:** The canvas div wraps the image element (instead of hiding it and using background-image). The image provides intrinsic sizing so the canvas responds to CSS layout naturally. All scale conversions go through `toRendered()`/`toNatural()` on `AnnotateImage`. Rescale is deferred during active edits to prevent data loss. + +**Tech Stack:** TypeScript, Vitest (jsdom), Playwright (e2e), esbuild (build) + +--- + +### Task 1: Add `toRendered()` and `toNatural()` utility methods + +These are pure methods with no dependencies on the C1 refactor. Build them first so later tasks can use them. + +**Files:** +- Modify: `src/annotate-image.ts:67-89` (add methods to class) +- Test: `test/annotate-image.test.ts` + +**Step 1: Write failing tests for `toRendered` and `toNatural`** + +Add a new `describe` block at the end of `test/annotate-image.test.ts`: + +```typescript +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); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/annotate-image.test.ts` +Expected: FAIL — `toRendered` and `toNatural` don't exist yet. + +**Step 3: Implement `toRendered` and `toNatural`** + +In `src/annotate-image.ts`, add these methods to the `AnnotateImage` class (after `scaleY` declaration, around line 89): + +```typescript + /** 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; + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/annotate-image.test.ts` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `npx vitest run` +Expected: ALL PASS — no regressions. + +**Step 6: Commit** + +```bash +git add src/annotate-image.ts test/annotate-image.test.ts +git commit -m "feat: add toRendered/toNatural coordinate conversion methods with isFinite guard" +``` + +--- + +### Task 2: Use `toRendered`/`toNatural` in view and edit (C2 fix) + +Replace all manual scale math with the new utility methods. + +**Files:** +- Modify: `src/annotate-view.ts:77-109` +- Modify: `src/annotate-edit.ts:51-55,161-168` +- Test: existing tests in `test/annotate-view.test.ts`, `test/annotate-edit.test.ts` + +**Step 1: Refactor `setPosition` in `annotate-view.ts`** + +Replace lines 77-84: + +```typescript + setPosition(): void { + const rendered = this.image.toRendered(this.note); + const innerDiv = this.area.firstElementChild as HTMLElement; + 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'; + } +``` + +**Step 2: Refactor `resetPosition` in `annotate-view.ts`** + +Replace lines 87-110. The key change: read natural coords from `editable.note` (already converted by the edit save handler), use `toRendered` for DOM positioning. No more re-deriving from inline styles: + +```typescript + resetPosition(editable: { area: HTMLElement; note: AnnotationNote }, text: string): void { + this.tooltip.textContent = text; + this.tooltip.style.display = 'none'; + + // 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 = 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; + } +``` + +Remove the now-unused `readInlinePosition` and `readInlineSize` imports from `annotate-view.ts` line 1-4 (keep the exports — `annotate-edit.ts` still uses them). + +**Step 3: Refactor edit area positioning in `annotate-edit.ts`** + +Replace lines 51-55 in the constructor: + +```typescript + 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'; +``` + +**Step 4: Refactor save handler in `annotate-edit.ts`** + +Replace lines 161-169: + +```typescript + // Update note from current area position (convert rendered back to natural) + const pos = readInlinePosition(this.area); + const size = readInlineSize(this.area); + 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; +``` + +**Step 5: Run full test suite** + +Run: `npx vitest run` +Expected: ALL PASS — the behavior is identical, just the code path is consolidated. + +**Step 6: Commit** + +```bash +git add src/annotate-view.ts src/annotate-edit.ts +git commit -m "refactor: use toRendered/toNatural for all scale conversions (C2 fix)" +``` + +--- + +### Task 3: Wrap image inside canvas (C1 fix) + +This is the core architectural change. The constructor wraps the image inside the canvas div instead of hiding it and using background-image. CSS handles overlay sizing. + +**Files:** +- Modify: `src/annotate-image.ts:95-174` (constructor), `src/annotate-image.ts:250-274` (destroy), `src/annotate-image.ts:284-304` (rescale) +- Modify: `src/annotation.css:33-62` +- Test: `test/annotate-image.test.ts`, `test/destroy.test.ts` + +**Step 1: Update CSS** + +In `src/annotation.css`, replace lines 33-63: + +```css +.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%; + width: 100%; +} +``` + +Note the changes from the current CSS: +- Removed: `background-position`, `background-repeat` (no more background-image) +- Changed: `display: block` → `display: inline-block` (so the canvas wraps tightly around the image, not 100% of parent) +- Added: `.image-annotate-canvas > img` rule + +Replace lines 60-63 (view overlay): + +```css +.image-annotate-view { + display: none; + inset: 0; + position: absolute; +} +``` + +Replace lines 122-124 (edit overlay): + +```css +.image-annotate-edit { + display: none; + inset: 0; + position: absolute; +} +``` + +**Step 2: Update failing tests for new DOM structure** + +In `test/annotate-image.test.ts`, update the initialization tests (lines 20-40): + +Replace test at line 20-27: + +```typescript + test('canvas wraps the original image', () => { + const image = createTestImage(); + const inst = getInstance(image); + + expect(inst.canvas.contains(image[0])).toBe(true); + expect(image[0].parentElement).toBe(inst.canvas); + }); +``` + +Remove (delete) the test at lines 29-34 (`sets canvas background-image from the img src`) — no longer applicable. + +Replace test at lines 36-40: + +```typescript + test('image is visible inside the canvas', () => { + const image = createTestImage(); + + expect(image[0].style.display).not.toBe('none'); + }); +``` + +In `test/destroy.test.ts`, update lines 15-22: + +```typescript + test('restores image to original DOM position', () => { + const image = createTestImage(); + const inst = getInstance(image); + + inst.destroy(); + // 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(); + }); +``` + +**Step 3: Run tests to verify they fail** + +Run: `npx vitest run test/annotate-image.test.ts test/destroy.test.ts` +Expected: FAIL — constructor still uses background-image approach. + +**Step 4: Refactor the constructor** + +In `src/annotate-image.ts`, add fields for DOM restoration (after `private resizeObserver?: ResizeObserver;` at line 81): + +```typescript + private originalParent: Node | null = null; + private originalNextSibling: Node | null = null; +``` + +Replace the constructor body from line 113 (`this.notes = ...`) through line 162 (`img.style.display = 'none'`). Keep everything before line 113 (dimension reading, scale computation) and everything after line 162 (ResizeObserver setup). + +New constructor body (replacing lines 113-162): + +```typescript + 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'; + + this.viewOverlay = document.createElement('div'); + this.viewOverlay.className = 'image-annotate-view'; + + this.editOverlay = document.createElement('div'); + this.editOverlay.className = 'image-annotate-edit'; + this.editOverlay.style.display = 'none'; + const editArea = document.createElement('div'); + editArea.className = 'image-annotate-edit-area'; + this.editOverlay.appendChild(editArea); + + // 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); + 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) : {}; + if (this.api.load) { + this.loadFromApi(); + } else { + this.load(); + } + + // Add Note button + if (this.options.editable) { + this.createButton(); + } +``` + +Note what's removed: +- No `this.canvas.style.height/width` — image provides sizing +- No `this.canvas.style.backgroundImage/backgroundSize` — image is visible +- No `this.viewOverlay.style.height/width` — CSS `inset: 0` handles it +- No `this.editOverlay.style.height/width` — CSS `inset: 0` handles it +- No `img.style.display = 'none'` — image stays visible + +**Step 5: Refactor `destroy()`** + +Replace lines 250-274: + +```typescript + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + + // Destroy views without firing onChange + this.destroyViews(); + this.notes = []; + + // Remove "Add Note" button + if (this.button) { + this.button.remove(); + } + + // Disconnect ResizeObserver + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = undefined; + } + + // Restore image to its original DOM position + if (this.originalParent) { + this.originalParent.insertBefore(this.img, this.originalNextSibling); + } + + // Remove canvas from DOM + this.canvas.remove(); + } +``` + +**Step 6: Simplify `rescale()`** + +Replace lines 284-304. No longer needs to update overlay dimensions (CSS handles that): + +```typescript + private rescale(renderedWidth: number, renderedHeight: number): void { + const newScaleX = renderedWidth / this.naturalWidth; + const newScaleY = renderedHeight / this.naturalHeight; + + // Skip if nothing changed + if (newScaleX === this.scaleX && newScaleY === this.scaleY) return; + + this.scaleX = newScaleX; + this.scaleY = newScaleY; + + // Rebuild views at new scale + this.destroyViews(); + this.createViews(); + } +``` + +**Step 7: Run tests** + +Run: `npx vitest run` +Expected: ALL PASS — existing scaling tests work because `createScaledTestImage` mocks `getBoundingClientRect()` and the scale math is unchanged. + +**Step 8: Commit** + +```bash +git add src/annotate-image.ts src/annotation.css test/annotate-image.test.ts test/destroy.test.ts +git commit -m "refactor: wrap image inside canvas instead of using background-image (C1 fix)" +``` + +--- + +### Task 4: Defer rescale during active edits (H1 fix) + +**Files:** +- Modify: `src/annotate-image.ts` (rescale, cancelEdit, and post-save/delete flush) +- Test: `test/annotate-image.test.ts` + +**Step 1: Write failing tests** + +Add to `test/annotate-image.test.ts` inside the `auto-scaling — ResizeObserver` describe block: + +```typescript + 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 + + // 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 + + // 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); + }); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/annotate-image.test.ts` +Expected: FAIL — rescale currently runs immediately during edits. + +**Step 3: Implement deferred rescale** + +In `src/annotate-image.ts`, add the `pendingRescale` field (near the other private fields): + +```typescript + private pendingRescale = false; +``` + +Modify `rescale()` to defer when in edit mode: + +```typescript + private rescale(renderedWidth: number, renderedHeight: number): void { + if (this.mode === 'edit') { + this.pendingRescale = true; + return; + } + this.applyRescale(renderedWidth, renderedHeight); + } + + 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(); + } +``` + +Add a `flushPendingRescale()` method: + +```typescript + /** @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); + } + } +``` + +Modify `cancelEdit()` to flush: + +```typescript + cancelEdit(): void { + if (this.activeEdit) { + this.activeEdit.destroy(); + this.setMode('view'); + } + this.flushPendingRescale(); + } +``` + +In `src/annotate-edit.ts`, add flush calls after save and delete complete. In `commitSave()` (around line 147-158), add after `this.destroy()`: + +```typescript + this.image.flushPendingRescale(); +``` + +In `removeNote()` (around line 203-209), add after `this.image.notifyDelete(...)`: + +```typescript + this.image.flushPendingRescale(); +``` + +**Step 4: Run tests** + +Run: `npx vitest run` +Expected: ALL PASS + +**Step 5: Commit** + +```bash +git add src/annotate-image.ts src/annotate-edit.ts test/annotate-image.test.ts +git commit -m "fix: defer rescale during active edits to prevent data loss (H1 fix)" +``` + +--- + +### Task 5: ResizeObserver edge case tests (M2/M3) + +**Files:** +- Test: `test/annotate-image.test.ts` + +**Step 1: Add edge case tests** + +Add to the `auto-scaling — ResizeObserver` describe block: + +```typescript + 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); + }); +``` + +**Step 2: Run tests** + +Run: `npx vitest run test/annotate-image.test.ts` +Expected: ALL PASS — these guards already exist in the code. + +**Step 3: Commit** + +```bash +git add test/annotate-image.test.ts +git commit -m "test: add edge case tests for ResizeObserver guards (M2/M3)" +``` + +--- + +### Task 6: Idiomatic framework defaults (H3) and prop tests (H4/H5) + +**Files:** +- Modify: `src/react.tsx:83` +- Modify: `src/vue.ts:29` +- Test: `test/react.test.tsx` +- Test: `test/vue.test.ts` + +**Step 1: Write failing tests for React `autoResize` passthrough** + +Add to `test/react.test.tsx`: + +```typescript + describe('autoResize prop', () => { + it('defaults autoResize to true', () => { + render(); + // The canvas should have been created (basic sanity) + expect(document.querySelector('.image-annotate-canvas')).not.toBeNull(); + }); + + it('passes autoResize={false} to core', () => { + render( + + ); + expect(document.querySelector('.image-annotate-canvas')).not.toBeNull(); + }); + }); +``` + +**Step 2: Write failing tests for Vue `autoResize` passthrough** + +Add to `test/vue.test.ts`: + +```typescript + describe('autoResize prop', () => { + it('defaults autoResize to true', () => { + 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(); + }); + }); +``` + +**Step 3: Fix React default** + +In `src/react.tsx`, change line 83 from: + +```typescript + autoResize: props.autoResize, +``` + +to: + +```typescript + autoResize: props.autoResize ?? true, +``` + +**Step 4: Fix Vue default** + +In `src/vue.ts`, change line 29 from: + +```typescript + autoResize: { type: Boolean, default: undefined }, +``` + +to: + +```typescript + autoResize: { type: Boolean, default: true }, +``` + +**Step 5: Run tests** + +Run: `npx vitest run test/react.test.tsx test/vue.test.ts` +Expected: ALL PASS + +**Step 6: Run full test suite** + +Run: `npx vitest run` +Expected: ALL PASS + +**Step 7: Commit** + +```bash +git add src/react.tsx src/vue.ts test/react.test.tsx test/vue.test.ts +git commit -m "fix: idiomatic framework defaults for autoResize, add prop tests (H3/H4/H5)" +``` + +--- + +### Task 7: Add `autoResize` to defaults and fix demo (D3, L1) + +**Files:** +- Modify: `src/index.ts:16-20` +- Modify: `demo/scaling.html:70` + +**Step 1: Add `autoResize` to defaults** + +In `src/index.ts`, change line 16-20: + +```typescript +const defaults: AnnotateImageOptions = { + editable: true, + notes: [], + autoResize: true, + labels: { ...DEFAULT_LABELS }, +}; +``` + +**Step 2: Fix `var` in demo** + +In `demo/scaling.html`, change line 70 from `var notes` to `const notes`. + +**Step 3: Run type-check** + +Run: `npx tsc --noEmit` +Expected: PASS + +**Step 4: Commit** + +```bash +git add src/index.ts demo/scaling.html +git commit -m "chore: add autoResize to defaults (D3), fix var in demo (L1)" +``` + +--- + +### Task 8: Update test helpers for new DOM structure + +The `createTestImage` and `createTestImageVanilla` helpers may need adjustment since the image is now inside the canvas. + +**Files:** +- Modify: `test/setup.ts` (if needed) + +**Step 1: Run full test suite** + +Run: `npx vitest run` +Expected: Check which tests pass/fail. If all pass, skip this task. + +**Step 2: Fix any remaining test failures** + +If tests fail because they expect the image to be a sibling of the canvas, update the assertions. The test helpers themselves should not need changes — they create an `` in `document.body`, and the constructor now wraps it in the canvas. + +The key thing: after construction, `image[0].parentElement` is now the canvas div, not `document.body`. Any test that relies on `image[0].nextElementSibling === canvas` will fail and needs updating. + +**Step 3: Run full test suite again** + +Run: `npx vitest run` +Expected: ALL PASS + +**Step 4: Commit if changes were made** + +```bash +git add test/ +git commit -m "test: update test assertions for image-wrapping DOM structure" +``` + +--- + +### Task 9: Update documentation + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `readme.md` + +**Step 1: Update CLAUDE.md** + +In the Architecture section, update the description of AnnotateImage to mention wrapping: + +> **`AnnotateImage`** — Orchestrates the plugin. Wraps the target image in a canvas div with view/edit overlays... + +Remove references to `background-image` or "hiding the original image." + +**Step 2: Update readme.md** + +Update the Scaling section if it mentions background-image or canvas sizing. + +**Step 3: Commit** + +```bash +git add CLAUDE.md readme.md +git commit -m "docs: update for canvas-wrapping architecture" +``` + +--- + +### Task 10: Build and verify + +**Step 1: Run type-check** + +Run: `npx tsc --noEmit` +Expected: PASS + +**Step 2: Run full unit test suite** + +Run: `npx vitest run` +Expected: ALL PASS + +**Step 3: Run jQuery 4 tests** + +Run: `npm run test:jquery4` +Expected: ALL PASS + +**Step 4: Build** + +Run: `npm run build` +Expected: Clean build, no errors. + +**Step 5: Run E2E tests** + +Run: `npm run test:e2e` +Expected: ALL PASS (may need E2E test updates if they assert on background-image or sibling structure). + +**Step 6: Commit any E2E fixes** + +If E2E tests need updating: + +```bash +git add e2e/ +git commit -m "test: update E2E tests for canvas-wrapping architecture" +``` From a8512d388a7be9791b9ec294b6313ab2575040d5 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 21:06:10 +0100 Subject: [PATCH 16/29] feat: add toRendered/toNatural coordinate conversion methods with isFinite guard --- src/annotate-image.ts | 25 +++++++++++++++++++++ test/annotate-image.test.ts | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/annotate-image.ts b/src/annotate-image.ts index b122053..b4496a9 100644 --- a/src/annotate-image.ts +++ b/src/annotate-image.ts @@ -88,6 +88,31 @@ export class AnnotateImage { /** 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. * @param options - Plugin configuration. diff --git a/test/annotate-image.test.ts b/test/annotate-image.test.ts index e4ff5c8..01bea84 100644 --- a/test/annotate-image.test.ts +++ b/test/annotate-image.test.ts @@ -757,3 +757,48 @@ describe('auto-scaling — scale factor computation', () => { 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); + }); +}); From 83a23fa8f3780b8d12221d39c5528c19bf294d45 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 21:12:32 +0100 Subject: [PATCH 17/29] refactor: use toRendered/toNatural for all scale conversions (C2 fix) Replace manual scaleX/scaleY multiplication and division in annotate-view.ts and annotate-edit.ts with centralized toRendered() and toNatural() methods. resetPosition now reads natural coords from the edit note (set by save handler) instead of re-deriving them from rendered area styles. --- src/annotate-edit.ts | 23 +++++++++++++---------- src/annotate-view.ts | 37 +++++++++++++++++-------------------- test/annotate-view.test.ts | 9 +++------ 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/src/annotate-edit.ts b/src/annotate-edit.ts index e4511c1..f083f54 100644 --- a/src/annotate-edit.ts +++ b/src/annotate-edit.ts @@ -48,11 +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; - const { scaleX, scaleY } = image; - this.area.style.height = (this.note.height * scaleY) + 'px'; - this.area.style.width = (this.note.width * scaleX) + 'px'; - this.area.style.left = (this.note.left * scaleX) + 'px'; - this.area.style.top = (this.note.top * scaleY) + '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'); @@ -159,13 +159,16 @@ export class AnnotateEdit { }; // Update note from current area position (convert rendered back to natural) - const { scaleX, scaleY } = this.image; const pos = readInlinePosition(this.area); const size = readInlineSize(this.area); - this.note.top = pos.top / scaleY; - this.note.left = pos.left / scaleX; - this.note.width = size.width / scaleX; - this.note.height = size.height / scaleY; + 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) { diff --git a/src/annotate-view.ts b/src/annotate-view.ts index fd85016..3096b9a 100644 --- a/src/annotate-view.ts +++ b/src/annotate-view.ts @@ -75,35 +75,32 @@ export class AnnotateView { /** Apply the note's position and dimensions to the area element, scaled to rendered size. */ setPosition(): void { - const { scaleX, scaleY } = this.image; + const rendered = this.image.toRendered(this.note); const innerDiv = this.area.firstElementChild as HTMLElement; - innerDiv.style.height = (this.note.height * scaleY) + 'px'; - innerDiv.style.width = (this.note.width * scaleX) + 'px'; - this.area.style.left = (this.note.left * scaleX) + 'px'; - this.area.style.top = (this.note.top * scaleY) + '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. */ resetPosition(editable: { area: HTMLElement; note: AnnotationNote }, text: string): void { - const { scaleX, scaleY } = this.image; this.tooltip.textContent = text; this.tooltip.style.display = 'none'; - const areaPos = readInlinePosition(editable.area); - const areaSize = readInlineSize(editable.area); - - // Apply rendered coordinates to view DOM + // 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'; - - // Convert rendered coordinates back to natural for storage - this.note.top = areaPos.top / scaleY; - this.note.left = areaPos.left / scaleX; - this.note.height = areaSize.height / scaleY; - this.note.width = areaSize.width / scaleX; + 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/test/annotate-view.test.ts b/test/annotate-view.test.ts index 0662a8d..36c5e97 100644 --- a/test/annotate-view.test.ts +++ b/test/annotate-view.test.ts @@ -304,19 +304,16 @@ describe('auto-scaling — view positioning', () => { expect(view.area.style.top).toBe('100px'); }); - test('resetPosition converts rendered position back to natural coordinates', () => { + 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 }, + note: { ...note, top: 50, left: 100, width: 80, height: 60 }, }; - fakeEditable.area.style.left = '50px'; - fakeEditable.area.style.top = '25px'; - fakeEditable.area.style.width = '40px'; - fakeEditable.area.style.height = '30px'; view.resetPosition(fakeEditable as any, 'updated text'); From e6b4d896d3d60e4834cfab4fc87d10b5b5785623 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 21:20:54 +0100 Subject: [PATCH 18/29] refactor: wrap image inside canvas instead of using background-image (C1 fix) The image element is now a visible child of the canvas div instead of being hidden and replaced with a CSS background-image. This provides intrinsic sizing via the image itself and simplifies overlay positioning with CSS inset:0. The destroy() method restores the image to its original DOM position with guards for multi-instance and framework (React) unmount scenarios. --- src/annotate-image.ts | 53 +++++++++++++++++-------------------- src/annotation.css | 17 ++++++++---- src/react.tsx | 6 ++++- test/annotate-image.test.ts | 28 +++++++------------- test/core-api.test.ts | 13 ++++----- test/destroy.test.ts | 7 ++--- 6 files changed, 63 insertions(+), 61 deletions(-) diff --git a/src/annotate-image.ts b/src/annotate-image.ts index b4496a9..1e0a3b6 100644 --- a/src/annotate-image.ts +++ b/src/annotate-image.ts @@ -79,6 +79,8 @@ export class AnnotateImage { activeEdit: AnnotateEdit | null = null; private destroyed = 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. */ @@ -137,7 +139,11 @@ export class AnnotateImage { this.scaleY = renderedHeight / this.naturalHeight; this.notes = options.notes.map(n => ({ ...n })); - // Build canvas structure + // 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'; @@ -151,24 +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 = renderedHeight + 'px'; - this.canvas.style.width = renderedWidth + 'px'; - this.canvas.style.backgroundImage = 'url("' + img.src + '")'; - this.canvas.style.backgroundSize = '100% 100%'; - this.viewOverlay.style.height = renderedHeight + 'px'; - this.viewOverlay.style.width = renderedWidth + 'px'; - this.editOverlay.style.height = renderedHeight + 'px'; - this.editOverlay.style.width = renderedWidth + '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) : {}; @@ -183,9 +179,6 @@ 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) => { @@ -291,11 +284,21 @@ export class AnnotateImage { 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. */ @@ -317,12 +320,6 @@ export class AnnotateImage { this.scaleX = newScaleX; this.scaleY = newScaleY; - // Update overlay dimensions - this.viewOverlay.style.height = renderedHeight + 'px'; - this.viewOverlay.style.width = renderedWidth + 'px'; - this.editOverlay.style.height = renderedHeight + 'px'; - this.editOverlay.style.width = renderedWidth + 'px'; - // Rebuild views at new scale this.destroyViews(); this.createViews(); diff --git a/src/annotation.css b/src/annotation.css index 6b6bfb8..099b86f 100644 --- a/src/annotation.css +++ b/src/annotation.css @@ -50,16 +50,21 @@ --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%; + 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 +126,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/react.tsx b/src/react.tsx index 0595ce5..bae62be 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -122,6 +122,10 @@ export const AnnotateImage = forwardRef( getNotes() { return instanceRef.current?.getNotes() ?? []; }, })); - return {props.alt}; + return ( + + {props.alt} + + ); }, ); diff --git a/test/annotate-image.test.ts b/test/annotate-image.test.ts index 01bea84..17c1dcb 100644 --- a/test/annotate-image.test.ts +++ b/test/annotate-image.test.ts @@ -17,26 +17,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', () => { - const image = createTestImage(); - const inst = getInstance(image); - - expect(inst.canvas.style.backgroundImage).toContain('test.jpg'); - }); - - test('hides the original image', () => { + test('image is visible inside the canvas', () => { 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)', () => { @@ -740,15 +732,15 @@ describe('auto-scaling — scale factor computation', () => { expect(inst.scaleY).toBe(1); }); - test('canvas dimensions match rendered size, not natural size', () => { + test('canvas does not set inline width/height (image provides sizing)', () => { const inst = createScaledTestImage(400, 300, 200, 150); - expect(inst.canvas.style.width).toBe('200px'); - expect(inst.canvas.style.height).toBe('150px'); + expect(inst.canvas.style.width).toBe(''); + expect(inst.canvas.style.height).toBe(''); }); - test('canvas background-size is set to 100% 100%', () => { + test('canvas does not set background-image (image is visible child)', () => { const inst = createScaledTestImage(400, 300, 200, 150); - expect(inst.canvas.style.backgroundSize).toBe('100% 100%'); + expect(inst.canvas.style.backgroundImage).toBe(''); }); test('naturalWidth and naturalHeight are stored', () => { 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', () => { From 61eb711f3548da3125e0bf575861806c288eaffb Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 21:26:49 +0100 Subject: [PATCH 19/29] fix: defer rescale during active edits to prevent data loss (H1 fix) When a ResizeObserver fires during an active edit (e.g., mobile keyboard show/hide), the rescale is now deferred instead of immediately destroying the edit form. The pending rescale is flushed after save, delete, or cancel. --- src/annotate-edit.ts | 2 ++ src/annotate-image.ts | 25 ++++++++++++++++++++++--- test/annotate-image.test.ts | 36 +++++++++++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/annotate-edit.ts b/src/annotate-edit.ts index f083f54..26a61a8 100644 --- a/src/annotate-edit.ts +++ b/src/annotate-edit.ts @@ -156,6 +156,7 @@ export class AnnotateEdit { } this.image.notifySave(stripInternals(this.note)); this.destroy(); + this.image.flushPendingRescale(); }; // Update note from current area position (convert rendered back to natural) @@ -210,6 +211,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 1e0a3b6..65d837d 100644 --- a/src/annotate-image.ts +++ b/src/annotate-image.ts @@ -78,6 +78,7 @@ 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; @@ -307,24 +308,42 @@ export class AnnotateImage { this.activeEdit.destroy(); this.setMode('view'); } + this.flushPendingRescale(); } - /** Recompute scale factors and re-render all views for new container dimensions. */ + /** 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; - // Skip if nothing changed if (newScaleX === this.scaleX && newScaleY === this.scaleY) return; this.scaleX = newScaleX; this.scaleY = newScaleY; - // Rebuild views at new scale 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; diff --git a/test/annotate-image.test.ts b/test/annotate-image.test.ts index 17c1dcb..cfae507 100644 --- a/test/annotate-image.test.ts +++ b/test/annotate-image.test.ts @@ -701,15 +701,45 @@ describe('auto-scaling — ResizeObserver', () => { expect(area.style.top).toBe('50px'); }); - test('resize cancels active edit', () => { + 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 } }]); - expect(inst.mode).toBe('view'); - expect(inst.activeEdit).toBeNull(); + // 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); }); }); From 0c284cfb81e94f1f62e766e730c77c562f9ccb1e Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 21:29:48 +0100 Subject: [PATCH 20/29] test: add edge case tests for ResizeObserver guards (M2/M3) --- test/annotate-image.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/annotate-image.test.ts b/test/annotate-image.test.ts index cfae507..dd79eea 100644 --- a/test/annotate-image.test.ts +++ b/test/annotate-image.test.ts @@ -741,6 +741,33 @@ describe('auto-scaling — ResizeObserver', () => { expect(inst.scaleX).toBe(0.5); expect(inst.scaleY).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', () => { From efed1bfa6fe51cb7950cf56562e6d375c532d967 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 21:33:12 +0100 Subject: [PATCH 21/29] fix: use idiomatic framework defaults for autoResize prop (H3/H4/H5) React: pass autoResize ?? true to core (was passing undefined when omitted). Vue: change autoResize prop default from undefined to true. Add autoResize prop tests for both wrappers. --- src/react.tsx | 2 +- src/vue.ts | 2 +- test/react.test.tsx | 14 ++++++++++++++ test/vue.test.ts | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/react.tsx b/src/react.tsx index bae62be..124ac44 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -80,7 +80,7 @@ export const AnnotateImage = forwardRef( try { const instance = new AnnotateImageCore(imgRef.current, { editable: props.editable ?? true, - autoResize: props.autoResize, + autoResize: props.autoResize ?? true, notes: props.notes ? props.notes.slice() : [], onChange: (notes) => onChangeRef.current?.(notes), onSave: (note) => onSaveRef.current?.(note), diff --git a/src/vue.ts b/src/vue.ts index 7aeb194..62a8434 100644 --- a/src/vue.ts +++ b/src/vue.ts @@ -26,7 +26,7 @@ export const AnnotateImage = defineComponent({ /** Enable annotation editing. Default: true. */ editable: { type: Boolean, default: true }, /** Enable automatic re-scaling when the container resizes. Default: true. */ - autoResize: { type: Boolean, default: undefined }, + autoResize: { type: Boolean, default: true }, }, emits: { 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/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(); + }); + }); }); From 10f85badb70ae8567355b90cbf72f9b91505a35f Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 27 Feb 2026 21:33:59 +0100 Subject: [PATCH 22/29] chore: add autoResize to defaults (D3), fix var in demo (L1) --- demo/scaling.html | 2 +- src/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/demo/scaling.html b/demo/scaling.html index 3e44902..112ae8e 100644 --- a/demo/scaling.html +++ b/demo/scaling.html @@ -67,7 +67,7 @@

    Responsive Container (50% width)

    - - -``` - -## Automatic Scaling - -v2 automatically scales annotations to match the rendered image size. If your image is displayed smaller than its natural dimensions (via CSS or HTML attributes), annotations position correctly without any configuration. - -Annotation coordinates are always stored in natural image pixels. The plugin computes a scale factor at render time. - -Dynamic resizing is enabled by default via `ResizeObserver`. Disable with `autoResize: false`. - -## Build System - -- Bower and Grunt have been removed. -- Build uses npm scripts and esbuild. -- Output includes ESM and IIFE bundles plus TypeScript declarations. -- See [Build Output](../readme.md#build-output) in the README. diff --git a/docs/plans/2026-02-27-code-review-fixes-design.md b/docs/plans/2026-02-27-code-review-fixes-design.md deleted file mode 100644 index dabd4d8..0000000 --- a/docs/plans/2026-02-27-code-review-fixes-design.md +++ /dev/null @@ -1,241 +0,0 @@ -# Code Review Fixes — Design - -Fixes for all issues identified in the v2-beta-2 branch review. - -## C1: Wrap image instead of replacing it - -### Problem - -The constructor hides the image (`display: none`) and creates a sibling canvas div with `background-image`. The canvas gets fixed inline `style.width/height` matching the image's rendered size at init time. This means: - -- The canvas **can never grow** beyond its initial rendered width (inline style caps it, even though CSS `max-width: 100%` allows shrinking) -- The `backgroundImage` URL is built via string concatenation without escaping -- Developers can't apply CSS to the canvas the way they'd expect — it doesn't behave like the image it replaced - -### Design - -**Wrap the image inside the canvas div** instead of hiding it and using `background-image`. - -#### Constructor changes - -Before: -``` -[img] → insertAfter → [canvas(bg-image)][img(hidden)] -``` - -After: -``` -[img] → wrap → [canvas > img + viewOverlay + editOverlay] -``` - -Steps: -1. Record `img.parentNode` and `img.nextSibling` for insertion -2. Create the canvas div -3. Insert canvas at the image's original position -4. Move the image inside the canvas as first child -5. Append viewOverlay and editOverlay after the image -6. Style the image: `display: block; width: 100%; height: auto;` so it fills and sizes the canvas -7. Do NOT set inline `width`/`height` on the canvas — let the image provide intrinsic sizing -8. Do NOT set `backgroundImage`/`backgroundSize` on the canvas -9. Read rendered dimensions from the canvas (after insertion) via `getBoundingClientRect()` for scale factor computation - -#### CSS changes - -Remove from `.image-annotate-canvas`: -- `background-position: left top` -- `background-repeat: no-repeat` - -Add: -```css -.image-annotate-canvas > img { - display: block; - width: 100%; - height: auto; -} -``` - -Change overlays to fill canvas without inline dimensions: -```css -.image-annotate-view, -.image-annotate-edit { - position: absolute; - inset: 0; -} -``` - -Remove inline `style.width`/`style.height` from viewOverlay and editOverlay in the constructor. - -#### Destroy changes - -Before: remove canvas, set `img.style.display = ''`. - -After: -1. Extract image from canvas, restore to original DOM position (before canvas) -2. Remove canvas -3. Clear any styles the plugin added to the image - -Store `originalNextSibling` and `originalParent` at construction time for restoration. - -#### ResizeObserver changes - -Still observes the canvas. The canvas now sizes itself from its image child, so it naturally responds to CSS layout changes. No inline dimensions to fight with. No feedback loop risk. - -`rescale()` no longer needs to update canvas or overlay dimensions — the CSS handles it. It only needs to: -1. Read new canvas dimensions via `getBoundingClientRect()` -2. Recompute scale factors -3. Rebuild annotation views - -#### What doesn't change - -- `interactions.ts` — drag/resize uses `getBoundingClientRect()` on the containment element, position-agnostic -- `jquery.annotate.ts` — passes raw `` to constructor, agnostic to DOM structure -- `src/react.tsx`, `src/vue.ts` — return ``, plugin handles DOM internally -- Annotation data model — still natural-pixel coordinates - -## C2: Consolidate coordinate conversion - -### Problem - -The rendered-to-natural conversion (`value / scale`) is duplicated in `annotate-edit.ts` (save handler) and `annotate-view.ts` (`resetPosition`). Both read the same inline styles and apply the same math to the same note object. - -### Design - -Add two utility methods to `AnnotateImage`: - -```typescript -/** 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 }) { - return { - top: rect.top / this.scaleY, - left: rect.left / this.scaleX, - width: rect.width / this.scaleX, - height: rect.height / this.scaleY, - }; -} -``` - -Then: -- **`annotate-edit.ts` save handler**: Convert rendered area position to natural via `this.image.toNatural(...)`, set on `this.note` -- **`annotate-view.ts` `resetPosition`**: Read natural coords from `editable.note` (already converted by edit), convert to rendered via `this.image.toRendered(...)` for DOM positioning. No independent re-derivation from inline styles. -- **`annotate-view.ts` `setPosition`**: Use `this.image.toRendered(this.note)` for DOM positioning -- **`annotate-edit.ts` constructor**: Use `this.image.toRendered(this.note)` for initial area positioning - -Single source of truth for all scale conversions. - -## H1: Defer rescale during active edits - -### Problem - -`rescale()` calls `destroyViews()` which calls `cancelEdit()`, silently discarding the user's in-progress text and position changes. This fires on any container resize, including mobile keyboard show/hide. - -### Design - -In `rescale()`, if `this.mode === 'edit'`, set a `pendingRescale` flag and return without rescaling. When the edit completes (save, delete, or cancel), check the flag and rescale then. - -```typescript -private pendingRescale = false; - -private rescale(renderedWidth: number, renderedHeight: number): void { - // Defer if user is mid-edit - if (this.mode === 'edit') { - this.pendingRescale = true; - return; - } - this.applyRescale(renderedWidth, renderedHeight); -} -``` - -In `cancelEdit()` and after save/delete complete, check and flush: - -```typescript -cancelEdit(): void { - if (this.activeEdit) { - this.activeEdit.destroy(); - this.setMode('view'); - } - if (this.pendingRescale) { - this.pendingRescale = false; - const rect = this.canvas.getBoundingClientRect(); - this.applyRescale(rect.width, rect.height); - } -} -``` - -## H2: Defense-in-depth `isFinite` guards - -### Problem - -Division by `scaleX`/`scaleY` could theoretically produce `Infinity` if scale factors are zero, corrupting stored coordinates. - -### Design - -Add guards in `toNatural()`: - -```typescript -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; -} -``` - -The edit save handler already has error reporting — the thrown error will be caught there. - -## H3: Idiomatic framework defaults - -### Problem - -Vue uses `default: undefined` for a Boolean prop (non-idiomatic). React passes `undefined` without applying a default (requires reading core source to understand behavior). - -### Design - -- **Vue**: `autoResize: { type: Boolean, default: true }` -- **React**: `autoResize: props.autoResize ?? true` in the options object passed to core - -## H4/H5: Framework prop passthrough tests - -### Design - -Add tests in both `test/react.test.tsx` and `test/vue.test.ts`: - -1. Default behavior — no `autoResize` prop → instance created with `autoResize: true` -2. Explicit `autoResize={false}` → forwarded correctly, no ResizeObserver attached - -Follow the existing pattern used for `editable` prop tests. - -## M2/M3: Edge case tests for rescale - -### Design - -Bundle into the C1 refactor. When writing tests for the new canvas-wrapping implementation, include: - -1. No-op path — rescale with unchanged dimensions doesn't rebuild views -2. Empty ResizeObserver entries — no crash -3. Zero-dimension entries — no crash, no rescale -4. Deferred rescale during edit (H1) — rescale fires after edit completes - -## L1: `var` → `const` in demo - -Change `var notes` to `const notes` in `demo/scaling.html`. - -## D3: `autoResize` in defaults - -Add `autoResize: true` to the defaults object in `src/index.ts` for self-documentation. diff --git a/docs/plans/2026-02-27-code-review-fixes-implementation.md b/docs/plans/2026-02-27-code-review-fixes-implementation.md deleted file mode 100644 index 40f26b1..0000000 --- a/docs/plans/2026-02-27-code-review-fixes-implementation.md +++ /dev/null @@ -1,902 +0,0 @@ -# Code Review Fixes Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Fix all issues from the v2-beta-2 branch code review — refactor canvas to wrap the image instead of replacing it, consolidate coordinate conversions, defer rescale during edits, add defense-in-depth guards, fix framework defaults, add missing tests. - -**Architecture:** The canvas div wraps the image element (instead of hiding it and using background-image). The image provides intrinsic sizing so the canvas responds to CSS layout naturally. All scale conversions go through `toRendered()`/`toNatural()` on `AnnotateImage`. Rescale is deferred during active edits to prevent data loss. - -**Tech Stack:** TypeScript, Vitest (jsdom), Playwright (e2e), esbuild (build) - ---- - -### Task 1: Add `toRendered()` and `toNatural()` utility methods - -These are pure methods with no dependencies on the C1 refactor. Build them first so later tasks can use them. - -**Files:** -- Modify: `src/annotate-image.ts:67-89` (add methods to class) -- Test: `test/annotate-image.test.ts` - -**Step 1: Write failing tests for `toRendered` and `toNatural`** - -Add a new `describe` block at the end of `test/annotate-image.test.ts`: - -```typescript -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); - }); -}); -``` - -**Step 2: Run tests to verify they fail** - -Run: `npx vitest run test/annotate-image.test.ts` -Expected: FAIL — `toRendered` and `toNatural` don't exist yet. - -**Step 3: Implement `toRendered` and `toNatural`** - -In `src/annotate-image.ts`, add these methods to the `AnnotateImage` class (after `scaleY` declaration, around line 89): - -```typescript - /** 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; - } -``` - -**Step 4: Run tests to verify they pass** - -Run: `npx vitest run test/annotate-image.test.ts` -Expected: PASS - -**Step 5: Run full test suite** - -Run: `npx vitest run` -Expected: ALL PASS — no regressions. - -**Step 6: Commit** - -```bash -git add src/annotate-image.ts test/annotate-image.test.ts -git commit -m "feat: add toRendered/toNatural coordinate conversion methods with isFinite guard" -``` - ---- - -### Task 2: Use `toRendered`/`toNatural` in view and edit (C2 fix) - -Replace all manual scale math with the new utility methods. - -**Files:** -- Modify: `src/annotate-view.ts:77-109` -- Modify: `src/annotate-edit.ts:51-55,161-168` -- Test: existing tests in `test/annotate-view.test.ts`, `test/annotate-edit.test.ts` - -**Step 1: Refactor `setPosition` in `annotate-view.ts`** - -Replace lines 77-84: - -```typescript - setPosition(): void { - const rendered = this.image.toRendered(this.note); - const innerDiv = this.area.firstElementChild as HTMLElement; - 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'; - } -``` - -**Step 2: Refactor `resetPosition` in `annotate-view.ts`** - -Replace lines 87-110. The key change: read natural coords from `editable.note` (already converted by the edit save handler), use `toRendered` for DOM positioning. No more re-deriving from inline styles: - -```typescript - resetPosition(editable: { area: HTMLElement; note: AnnotationNote }, text: string): void { - this.tooltip.textContent = text; - this.tooltip.style.display = 'none'; - - // 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 = 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; - } -``` - -Remove the now-unused `readInlinePosition` and `readInlineSize` imports from `annotate-view.ts` line 1-4 (keep the exports — `annotate-edit.ts` still uses them). - -**Step 3: Refactor edit area positioning in `annotate-edit.ts`** - -Replace lines 51-55 in the constructor: - -```typescript - 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'; -``` - -**Step 4: Refactor save handler in `annotate-edit.ts`** - -Replace lines 161-169: - -```typescript - // Update note from current area position (convert rendered back to natural) - const pos = readInlinePosition(this.area); - const size = readInlineSize(this.area); - 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; -``` - -**Step 5: Run full test suite** - -Run: `npx vitest run` -Expected: ALL PASS — the behavior is identical, just the code path is consolidated. - -**Step 6: Commit** - -```bash -git add src/annotate-view.ts src/annotate-edit.ts -git commit -m "refactor: use toRendered/toNatural for all scale conversions (C2 fix)" -``` - ---- - -### Task 3: Wrap image inside canvas (C1 fix) - -This is the core architectural change. The constructor wraps the image inside the canvas div instead of hiding it and using background-image. CSS handles overlay sizing. - -**Files:** -- Modify: `src/annotate-image.ts:95-174` (constructor), `src/annotate-image.ts:250-274` (destroy), `src/annotate-image.ts:284-304` (rescale) -- Modify: `src/annotation.css:33-62` -- Test: `test/annotate-image.test.ts`, `test/destroy.test.ts` - -**Step 1: Update CSS** - -In `src/annotation.css`, replace lines 33-63: - -```css -.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%; - width: 100%; -} -``` - -Note the changes from the current CSS: -- Removed: `background-position`, `background-repeat` (no more background-image) -- Changed: `display: block` → `display: inline-block` (so the canvas wraps tightly around the image, not 100% of parent) -- Added: `.image-annotate-canvas > img` rule - -Replace lines 60-63 (view overlay): - -```css -.image-annotate-view { - display: none; - inset: 0; - position: absolute; -} -``` - -Replace lines 122-124 (edit overlay): - -```css -.image-annotate-edit { - display: none; - inset: 0; - position: absolute; -} -``` - -**Step 2: Update failing tests for new DOM structure** - -In `test/annotate-image.test.ts`, update the initialization tests (lines 20-40): - -Replace test at line 20-27: - -```typescript - test('canvas wraps the original image', () => { - const image = createTestImage(); - const inst = getInstance(image); - - expect(inst.canvas.contains(image[0])).toBe(true); - expect(image[0].parentElement).toBe(inst.canvas); - }); -``` - -Remove (delete) the test at lines 29-34 (`sets canvas background-image from the img src`) — no longer applicable. - -Replace test at lines 36-40: - -```typescript - test('image is visible inside the canvas', () => { - const image = createTestImage(); - - expect(image[0].style.display).not.toBe('none'); - }); -``` - -In `test/destroy.test.ts`, update lines 15-22: - -```typescript - test('restores image to original DOM position', () => { - const image = createTestImage(); - const inst = getInstance(image); - - inst.destroy(); - // 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(); - }); -``` - -**Step 3: Run tests to verify they fail** - -Run: `npx vitest run test/annotate-image.test.ts test/destroy.test.ts` -Expected: FAIL — constructor still uses background-image approach. - -**Step 4: Refactor the constructor** - -In `src/annotate-image.ts`, add fields for DOM restoration (after `private resizeObserver?: ResizeObserver;` at line 81): - -```typescript - private originalParent: Node | null = null; - private originalNextSibling: Node | null = null; -``` - -Replace the constructor body from line 113 (`this.notes = ...`) through line 162 (`img.style.display = 'none'`). Keep everything before line 113 (dimension reading, scale computation) and everything after line 162 (ResizeObserver setup). - -New constructor body (replacing lines 113-162): - -```typescript - 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'; - - this.viewOverlay = document.createElement('div'); - this.viewOverlay.className = 'image-annotate-view'; - - this.editOverlay = document.createElement('div'); - this.editOverlay.className = 'image-annotate-edit'; - this.editOverlay.style.display = 'none'; - const editArea = document.createElement('div'); - editArea.className = 'image-annotate-edit-area'; - this.editOverlay.appendChild(editArea); - - // 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); - 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) : {}; - if (this.api.load) { - this.loadFromApi(); - } else { - this.load(); - } - - // Add Note button - if (this.options.editable) { - this.createButton(); - } -``` - -Note what's removed: -- No `this.canvas.style.height/width` — image provides sizing -- No `this.canvas.style.backgroundImage/backgroundSize` — image is visible -- No `this.viewOverlay.style.height/width` — CSS `inset: 0` handles it -- No `this.editOverlay.style.height/width` — CSS `inset: 0` handles it -- No `img.style.display = 'none'` — image stays visible - -**Step 5: Refactor `destroy()`** - -Replace lines 250-274: - -```typescript - destroy(): void { - if (this.destroyed) return; - this.destroyed = true; - - // Destroy views without firing onChange - this.destroyViews(); - this.notes = []; - - // Remove "Add Note" button - if (this.button) { - this.button.remove(); - } - - // Disconnect ResizeObserver - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - this.resizeObserver = undefined; - } - - // Restore image to its original DOM position - if (this.originalParent) { - this.originalParent.insertBefore(this.img, this.originalNextSibling); - } - - // Remove canvas from DOM - this.canvas.remove(); - } -``` - -**Step 6: Simplify `rescale()`** - -Replace lines 284-304. No longer needs to update overlay dimensions (CSS handles that): - -```typescript - private rescale(renderedWidth: number, renderedHeight: number): void { - const newScaleX = renderedWidth / this.naturalWidth; - const newScaleY = renderedHeight / this.naturalHeight; - - // Skip if nothing changed - if (newScaleX === this.scaleX && newScaleY === this.scaleY) return; - - this.scaleX = newScaleX; - this.scaleY = newScaleY; - - // Rebuild views at new scale - this.destroyViews(); - this.createViews(); - } -``` - -**Step 7: Run tests** - -Run: `npx vitest run` -Expected: ALL PASS — existing scaling tests work because `createScaledTestImage` mocks `getBoundingClientRect()` and the scale math is unchanged. - -**Step 8: Commit** - -```bash -git add src/annotate-image.ts src/annotation.css test/annotate-image.test.ts test/destroy.test.ts -git commit -m "refactor: wrap image inside canvas instead of using background-image (C1 fix)" -``` - ---- - -### Task 4: Defer rescale during active edits (H1 fix) - -**Files:** -- Modify: `src/annotate-image.ts` (rescale, cancelEdit, and post-save/delete flush) -- Test: `test/annotate-image.test.ts` - -**Step 1: Write failing tests** - -Add to `test/annotate-image.test.ts` inside the `auto-scaling — ResizeObserver` describe block: - -```typescript - 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 - - // 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 - - // 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); - }); -``` - -**Step 2: Run tests to verify they fail** - -Run: `npx vitest run test/annotate-image.test.ts` -Expected: FAIL — rescale currently runs immediately during edits. - -**Step 3: Implement deferred rescale** - -In `src/annotate-image.ts`, add the `pendingRescale` field (near the other private fields): - -```typescript - private pendingRescale = false; -``` - -Modify `rescale()` to defer when in edit mode: - -```typescript - private rescale(renderedWidth: number, renderedHeight: number): void { - if (this.mode === 'edit') { - this.pendingRescale = true; - return; - } - this.applyRescale(renderedWidth, renderedHeight); - } - - 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(); - } -``` - -Add a `flushPendingRescale()` method: - -```typescript - /** @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); - } - } -``` - -Modify `cancelEdit()` to flush: - -```typescript - cancelEdit(): void { - if (this.activeEdit) { - this.activeEdit.destroy(); - this.setMode('view'); - } - this.flushPendingRescale(); - } -``` - -In `src/annotate-edit.ts`, add flush calls after save and delete complete. In `commitSave()` (around line 147-158), add after `this.destroy()`: - -```typescript - this.image.flushPendingRescale(); -``` - -In `removeNote()` (around line 203-209), add after `this.image.notifyDelete(...)`: - -```typescript - this.image.flushPendingRescale(); -``` - -**Step 4: Run tests** - -Run: `npx vitest run` -Expected: ALL PASS - -**Step 5: Commit** - -```bash -git add src/annotate-image.ts src/annotate-edit.ts test/annotate-image.test.ts -git commit -m "fix: defer rescale during active edits to prevent data loss (H1 fix)" -``` - ---- - -### Task 5: ResizeObserver edge case tests (M2/M3) - -**Files:** -- Test: `test/annotate-image.test.ts` - -**Step 1: Add edge case tests** - -Add to the `auto-scaling — ResizeObserver` describe block: - -```typescript - 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); - }); -``` - -**Step 2: Run tests** - -Run: `npx vitest run test/annotate-image.test.ts` -Expected: ALL PASS — these guards already exist in the code. - -**Step 3: Commit** - -```bash -git add test/annotate-image.test.ts -git commit -m "test: add edge case tests for ResizeObserver guards (M2/M3)" -``` - ---- - -### Task 6: Idiomatic framework defaults (H3) and prop tests (H4/H5) - -**Files:** -- Modify: `src/react.tsx:83` -- Modify: `src/vue.ts:29` -- Test: `test/react.test.tsx` -- Test: `test/vue.test.ts` - -**Step 1: Write failing tests for React `autoResize` passthrough** - -Add to `test/react.test.tsx`: - -```typescript - describe('autoResize prop', () => { - it('defaults autoResize to true', () => { - render(); - // The canvas should have been created (basic sanity) - expect(document.querySelector('.image-annotate-canvas')).not.toBeNull(); - }); - - it('passes autoResize={false} to core', () => { - render( - - ); - expect(document.querySelector('.image-annotate-canvas')).not.toBeNull(); - }); - }); -``` - -**Step 2: Write failing tests for Vue `autoResize` passthrough** - -Add to `test/vue.test.ts`: - -```typescript - describe('autoResize prop', () => { - it('defaults autoResize to true', () => { - 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(); - }); - }); -``` - -**Step 3: Fix React default** - -In `src/react.tsx`, change line 83 from: - -```typescript - autoResize: props.autoResize, -``` - -to: - -```typescript - autoResize: props.autoResize ?? true, -``` - -**Step 4: Fix Vue default** - -In `src/vue.ts`, change line 29 from: - -```typescript - autoResize: { type: Boolean, default: undefined }, -``` - -to: - -```typescript - autoResize: { type: Boolean, default: true }, -``` - -**Step 5: Run tests** - -Run: `npx vitest run test/react.test.tsx test/vue.test.ts` -Expected: ALL PASS - -**Step 6: Run full test suite** - -Run: `npx vitest run` -Expected: ALL PASS - -**Step 7: Commit** - -```bash -git add src/react.tsx src/vue.ts test/react.test.tsx test/vue.test.ts -git commit -m "fix: idiomatic framework defaults for autoResize, add prop tests (H3/H4/H5)" -``` - ---- - -### Task 7: Add `autoResize` to defaults and fix demo (D3, L1) - -**Files:** -- Modify: `src/index.ts:16-20` -- Modify: `demo/scaling.html:70` - -**Step 1: Add `autoResize` to defaults** - -In `src/index.ts`, change line 16-20: - -```typescript -const defaults: AnnotateImageOptions = { - editable: true, - notes: [], - autoResize: true, - labels: { ...DEFAULT_LABELS }, -}; -``` - -**Step 2: Fix `var` in demo** - -In `demo/scaling.html`, change line 70 from `var notes` to `const notes`. - -**Step 3: Run type-check** - -Run: `npx tsc --noEmit` -Expected: PASS - -**Step 4: Commit** - -```bash -git add src/index.ts demo/scaling.html -git commit -m "chore: add autoResize to defaults (D3), fix var in demo (L1)" -``` - ---- - -### Task 8: Update test helpers for new DOM structure - -The `createTestImage` and `createTestImageVanilla` helpers may need adjustment since the image is now inside the canvas. - -**Files:** -- Modify: `test/setup.ts` (if needed) - -**Step 1: Run full test suite** - -Run: `npx vitest run` -Expected: Check which tests pass/fail. If all pass, skip this task. - -**Step 2: Fix any remaining test failures** - -If tests fail because they expect the image to be a sibling of the canvas, update the assertions. The test helpers themselves should not need changes — they create an `` in `document.body`, and the constructor now wraps it in the canvas. - -The key thing: after construction, `image[0].parentElement` is now the canvas div, not `document.body`. Any test that relies on `image[0].nextElementSibling === canvas` will fail and needs updating. - -**Step 3: Run full test suite again** - -Run: `npx vitest run` -Expected: ALL PASS - -**Step 4: Commit if changes were made** - -```bash -git add test/ -git commit -m "test: update test assertions for image-wrapping DOM structure" -``` - ---- - -### Task 9: Update documentation - -**Files:** -- Modify: `CLAUDE.md` -- Modify: `readme.md` - -**Step 1: Update CLAUDE.md** - -In the Architecture section, update the description of AnnotateImage to mention wrapping: - -> **`AnnotateImage`** — Orchestrates the plugin. Wraps the target image in a canvas div with view/edit overlays... - -Remove references to `background-image` or "hiding the original image." - -**Step 2: Update readme.md** - -Update the Scaling section if it mentions background-image or canvas sizing. - -**Step 3: Commit** - -```bash -git add CLAUDE.md readme.md -git commit -m "docs: update for canvas-wrapping architecture" -``` - ---- - -### Task 10: Build and verify - -**Step 1: Run type-check** - -Run: `npx tsc --noEmit` -Expected: PASS - -**Step 2: Run full unit test suite** - -Run: `npx vitest run` -Expected: ALL PASS - -**Step 3: Run jQuery 4 tests** - -Run: `npm run test:jquery4` -Expected: ALL PASS - -**Step 4: Build** - -Run: `npm run build` -Expected: Clean build, no errors. - -**Step 5: Run E2E tests** - -Run: `npm run test:e2e` -Expected: ALL PASS (may need E2E test updates if they assert on background-image or sibling structure). - -**Step 6: Commit any E2E fixes** - -If E2E tests need updating: - -```bash -git add e2e/ -git commit -m "test: update E2E tests for canvas-wrapping architecture" -``` From eb158fdd8a692cf29646ce535d2b167148ecf059 Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 6 Mar 2026 16:31:01 +0100 Subject: [PATCH 27/29] Update CI build trigger branch to "main" --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From bf89ff57c88ead13207da48f8e25bfdc4a1498bb Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 6 Mar 2026 16:36:56 +0100 Subject: [PATCH 28/29] Fix unit tests --- src/annotate-edit.ts | 6 ++-- src/annotate-image.ts | 11 +++----- test/annotate-image.test.ts | 56 ++++++++++++++++++++++++------------- test/annotate-view.test.ts | 2 +- test/setup.ts | 21 ++++++++++---- 5 files changed, 60 insertions(+), 36 deletions(-) diff --git a/src/annotate-edit.ts b/src/annotate-edit.ts index 26a61a8..32d6d87 100644 --- a/src/annotate-edit.ts +++ b/src/annotate-edit.ts @@ -163,8 +163,10 @@ export class AnnotateEdit { const pos = readInlinePosition(this.area); const size = readInlineSize(this.area); const natural = this.image.toNatural({ - top: pos.top, left: pos.left, - width: size.width, height: size.height, + top: pos.top, + left: pos.left, + width: size.width, + height: size.height, }); this.note.top = natural.top; this.note.left = natural.left; diff --git a/src/annotate-image.ts b/src/annotate-image.ts index 65d837d..a5fda43 100644 --- a/src/annotate-image.ts +++ b/src/annotate-image.ts @@ -109,8 +109,7 @@ export class AnnotateImage { width: rect.width / this.scaleX, height: rect.height / this.scaleY, }; - if (!isFinite(result.top) || !isFinite(result.left) || - !isFinite(result.width) || !isFinite(result.height)) { + 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; @@ -138,7 +137,7 @@ export class AnnotateImage { this.scaleX = renderedWidth / this.naturalWidth; this.scaleY = renderedHeight / this.naturalHeight; - this.notes = options.notes.map(n => ({ ...n })); + this.notes = options.notes.map((n) => ({ ...n })); // Record original DOM position for destroy restoration this.originalParent = img.parentNode; @@ -292,9 +291,7 @@ export class AnnotateImage { // 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; + const ref = this.originalNextSibling?.parentNode === this.originalParent ? this.originalNextSibling : null; this.originalParent.insertBefore(this.img, ref); } @@ -348,7 +345,7 @@ export class AnnotateImage { 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/test/annotate-image.test.ts b/test/annotate-image.test.ts index 87c6073..ae39a42 100644 --- a/test/annotate-image.test.ts +++ b/test/annotate-image.test.ts @@ -1,8 +1,6 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import '../src/jquery.annotate.ts'; import { createTestImage, getInstance, createScaledTestImage } from './setup.ts'; -import { AnnotateImage } from '../src/annotate-image.ts'; -import type { AnnotateImageOptions } from '../src/types.ts'; import type { AnnotateView } from '../src/annotate-view'; describe('annotateImage — initialization', () => { @@ -650,19 +648,24 @@ describe('stripInternals', () => { }); describe('auto-scaling — ResizeObserver', () => { - let observeCallback: ((entries: any[]) => void) | null = null; + let observeCallback: ((entries: ResizeObserverEntry[]) => void) | null = null; let disconnected = false; beforeEach(() => { observeCallback = null; disconnected = false; - vi.stubGlobal('ResizeObserver', class { - constructor(cb: (entries: any[]) => void) { - observeCallback = cb; - } - observe() {} - disconnect() { disconnected = true; } - }); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(cb: (entries: ResizeObserverEntry[]) => void) { + observeCallback = cb; + } + observe() {} + disconnect() { + disconnected = true; + } + }, + ); }); afterEach(() => { @@ -730,10 +733,17 @@ describe('auto-scaling — ResizeObserver', () => { // 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; }, + 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 @@ -757,10 +767,17 @@ describe('auto-scaling — ResizeObserver', () => { // 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; }, + x: 0, + y: 0, + left: 0, + top: 0, + right: 200, + bottom: 150, + width: 200, + height: 150, + toJSON() { + return this; + }, }); // Save the edit @@ -865,8 +882,7 @@ describe('toRendered / toNatural coordinate conversion', () => { 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'); + expect(() => inst.toNatural({ top: 50, left: 100, width: 40, height: 30 })).toThrow('non-finite coordinates'); }); test('round-trip: toRendered then toNatural returns original values', () => { diff --git a/test/annotate-view.test.ts b/test/annotate-view.test.ts index 36c5e97..7d47cc3 100644 --- a/test/annotate-view.test.ts +++ b/test/annotate-view.test.ts @@ -315,7 +315,7 @@ describe('auto-scaling — view positioning', () => { note: { ...note, top: 50, left: 100, width: 80, height: 60 }, }; - view.resetPosition(fakeEditable as any, 'updated text'); + view.resetPosition(fakeEditable, 'updated text'); expect(view.note.left).toBe(100); expect(view.note.top).toBe(50); diff --git a/test/setup.ts b/test/setup.ts index 7138c10..9c91725 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -55,8 +55,10 @@ export function createTestImageVanilla(): HTMLImageElement { * an initialized AnnotateImage instance. Use for testing scale behavior. */ export function createScaledTestImage( - naturalW: number, naturalH: number, - renderedW: number, renderedH: number, + naturalW: number, + naturalH: number, + renderedW: number, + renderedH: number, options: Partial = {}, ): AnnotateImage { document.body.innerHTML = ''; @@ -67,10 +69,17 @@ export function createScaledTestImage( 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; }, + 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 }); From 886e2eefb378f10b6f81bfe12e69252fdf2a9bec Mon Sep 17 00:00:00 2001 From: Chris Wood Date: Fri, 6 Mar 2026 16:53:50 +0100 Subject: [PATCH 29/29] ci: use trusted publishing for npm, bump to 2.0.0-beta.2 - Replace NPM_TOKEN secret with OIDC trusted publishing - Add npm-publish environment and id-token permission - Add --provenance --access public flags - Bump version to 2.0.0-beta.2 --- .github/workflows/release.yml | 10 ++++++---- package.json | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) 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/package.json b/package.json index e3337a1..7b4549f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "annotate-image", - "version": "2.0.0-beta.1", + "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": {