diff --git a/demo/scaling.html b/demo/scaling.html index a2718d7..5eadd0f 100644 --- a/demo/scaling.html +++ b/demo/scaling.html @@ -20,6 +20,10 @@ font-style: italic; margin-top: 8px; } + .img-padded-bordered { + padding: 12px; + border: 4px solid #666; + } @@ -63,6 +67,12 @@

Responsive Container (50% width)

Try resizing the browser window.

+ +
+

Image with Padding & Border

+

The image has 12px padding and a 4px border. Annotations should be positioned within the image content area, not the padding/border zone.

+ Starry Night +
diff --git a/e2e/scaling.spec.ts b/e2e/scaling.spec.ts index 8d920d8..448930c 100644 --- a/e2e/scaling.spec.ts +++ b/e2e/scaling.spec.ts @@ -66,4 +66,61 @@ test.describe('Scaling', () => { expect(newBox.width).toBeLessThan(initialBox.width); } }); + + test('padded/bordered image: renders 4 annotations', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').nth(3); + const areas = canvas.locator('.image-annotate-area'); + await expect(areas).toHaveCount(4); + }); + + test('padded/bordered image: annotations are within image content area', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').nth(3); + const img = canvas.locator('img'); + const imgBox = await img.boundingBox(); + const areas = canvas.locator('.image-annotate-area'); + const count = await areas.count(); + + // The image has 12px padding + 4px border = 16px inset on each side. + // Annotations should be within the content area (inset from border-box by 16px). + const inset = 12 + 4; // padding + border + for (let i = 0; i < count; i++) { + const areaBox = await areas.nth(i).boundingBox(); + if (areaBox && imgBox) { + expect(areaBox.x).toBeGreaterThanOrEqual(imgBox.x + inset - 2); + expect(areaBox.y).toBeGreaterThanOrEqual(imgBox.y + inset - 2); + expect(areaBox.x + areaBox.width).toBeLessThanOrEqual(imgBox.x + imgBox.width - inset + 2); + expect(areaBox.y + areaBox.height).toBeLessThanOrEqual(imgBox.y + imgBox.height - inset + 2); + } + } + }); + + test('padded/bordered image: Add Note button is within image content area', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').nth(3); + const img = canvas.locator('img'); + const imgBox = await img.boundingBox(); + const button = canvas.locator('.image-annotate-add'); + + // Hover to make button visible + await canvas.hover(); + const btnBox = await button.boundingBox(); + + const inset = 12 + 4; // padding + border + if (btnBox && imgBox) { + // Button's right edge should be within the content area (with 8px margin from CSS) + expect(btnBox.x + btnBox.width).toBeLessThanOrEqual(imgBox.x + imgBox.width - inset + 1); + // Button's bottom edge should be within the content area + expect(btnBox.y + btnBox.height).toBeLessThanOrEqual(imgBox.y + imgBox.height - inset + 1); + } + }); + + test('padded/bordered image: hover shows tooltip', async ({ page }) => { + const canvas = page.locator('.image-annotate-canvas').nth(3); + const area = canvas.locator('.image-annotate-area').last(); + const tooltip = area.locator('.image-annotate-note'); + + await expect(tooltip).toBeHidden(); + await canvas.hover(); + await area.hover(); + await expect(tooltip).toBeVisible(); + }); }); diff --git a/package.json b/package.json index bd512b5..f9fc809 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "annotate-image", - "version": "2.0.0-beta.4", + "version": "2.0.0-beta.5", "description": "Create Flickr-like comment annotations on images — draw rectangles, add notes, save via AJAX or static data", "license": "GPL-2.0", "repository": { diff --git a/src/annotate-edit.ts b/src/annotate-edit.ts index 412ba82..cf5f63a 100644 --- a/src/annotate-edit.ts +++ b/src/annotate-edit.ts @@ -91,7 +91,7 @@ export class AnnotateEdit { area.style.height = rect.height + 'px'; }; this.handlers.makeResizable(area, { - containment: image.canvas, + containment: image.editOverlay, onResize: applyRect, onStop: (rect) => { applyRect(rect); @@ -99,7 +99,7 @@ export class AnnotateEdit { }, }); this.handlers.makeDraggable(area, { - containment: image.canvas, + containment: image.editOverlay, onDrag: (pos) => { area.style.left = pos.left + 'px'; area.style.top = pos.top + 'px'; diff --git a/src/annotate-image.ts b/src/annotate-image.ts index ac68b45..2f801b5 100644 --- a/src/annotate-image.ts +++ b/src/annotate-image.ts @@ -91,6 +91,8 @@ export class AnnotateImage { scaleX: number; /** Current vertical scale factor (rendered / natural). */ scaleY: number; + /** Padding+border insets from the image element (px). Zero when the image has no padding/border. */ + readonly contentInset: { top: number; right: number; bottom: number; left: number }; /** Convert a rect from natural image coordinates to rendered (scaled) coordinates. */ toRendered(rect: { top: number; left: number; width: number; height: number }) { @@ -125,12 +127,18 @@ export class AnnotateImage { this.handlers = createDefaultHandlers(); this.img = img; - // Read natural and rendered dimensions + // Read natural and rendered dimensions, subtracting padding/border to get content area 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; + const styles = getComputedStyle(img); + const insetTop = (parseFloat(styles.paddingTop) || 0) + (parseFloat(styles.borderTopWidth) || 0); + const insetRight = (parseFloat(styles.paddingRight) || 0) + (parseFloat(styles.borderRightWidth) || 0); + const insetBottom = (parseFloat(styles.paddingBottom) || 0) + (parseFloat(styles.borderBottomWidth) || 0); + const insetLeft = (parseFloat(styles.paddingLeft) || 0) + (parseFloat(styles.borderLeftWidth) || 0); + this.contentInset = { top: insetTop, right: insetRight, bottom: insetBottom, left: insetLeft }; + const renderedWidth = (rendered.width || img.width) - insetLeft - insetRight; + const renderedHeight = (rendered.height || img.height) - insetTop - insetBottom; if (this.naturalWidth === 0 || this.naturalHeight === 0) { throw new Error('image-annotate: image must have non-zero dimensions (is the image loaded?)'); @@ -151,6 +159,14 @@ export class AnnotateImage { this.canvas.dataset.theme = options.theme; } + // Set content inset CSS vars for overlay/button positioning (only when non-zero) + if (insetTop || insetRight || insetBottom || insetLeft) { + this.canvas.style.setProperty('--image-annotate-content-top', `${insetTop}px`); + this.canvas.style.setProperty('--image-annotate-content-right', `${insetRight}px`); + this.canvas.style.setProperty('--image-annotate-content-bottom', `${insetBottom}px`); + this.canvas.style.setProperty('--image-annotate-content-left', `${insetLeft}px`); + } + this.viewOverlay = document.createElement('div'); this.viewOverlay.className = 'image-annotate-view'; @@ -321,7 +337,9 @@ export class AnnotateImage { this.pendingRescale = true; return; } - this.applyRescale(renderedWidth, renderedHeight); + const contentWidth = renderedWidth - this.contentInset.left - this.contentInset.right; + const contentHeight = renderedHeight - this.contentInset.top - this.contentInset.bottom; + this.applyRescale(contentWidth, contentHeight); } /** Apply new scale factors and re-render all views. */ @@ -343,8 +361,10 @@ export class AnnotateImage { 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); + const contentWidth = rect.width - this.contentInset.left - this.contentInset.right; + const contentHeight = rect.height - this.contentInset.top - this.contentInset.bottom; + if (contentWidth > 0 && contentHeight > 0) { + this.applyRescale(contentWidth, contentHeight); } } diff --git a/src/annotate-view.ts b/src/annotate-view.ts index 7895d37..ca3163b 100644 --- a/src/annotate-view.ts +++ b/src/annotate-view.ts @@ -49,7 +49,7 @@ export class AnnotateView { // Create the tooltip (was called "form" in old code) this.tooltip = document.createElement('div'); this.tooltip.className = 'image-annotate-note'; - this.tooltip.textContent = note.text; + this.tooltip.textContent = note.text.trim() ? note.text : '(no annotation)'; this.tooltip.style.display = 'none'; this.area.appendChild(this.tooltip); @@ -86,7 +86,7 @@ export class AnnotateView { /** Update the view's position, size, and text from the edit area after a save. */ resetPosition(editable: { area: HTMLElement; note: AnnotationNote }, text: string): void { - this.tooltip.textContent = text; + this.tooltip.textContent = text.trim() ? text : '(no annotation)'; this.tooltip.style.display = 'none'; // Position view DOM using the note's natural coordinates (already converted by edit) diff --git a/src/annotation.css b/src/annotation.css index 6b732cd..30ca4ea 100644 --- a/src/annotation.css +++ b/src/annotation.css @@ -8,14 +8,14 @@ background-size: 20px 20px; border: var(--image-annotate-add-border); border-radius: var(--image-annotate-add-radius); - bottom: 8px; + bottom: calc(var(--image-annotate-content-bottom, 0px) + 8px); color: #fff; cursor: pointer; height: 32px; opacity: 0; padding: 0; position: absolute; - right: 8px; + right: calc(var(--image-annotate-content-right, 0px) + 8px); transition: opacity 0.2s, background-color 0.2s; @@ -80,7 +80,8 @@ } .image-annotate-view { display: none; - inset: 0; + inset: var(--image-annotate-content-top, 0px) var(--image-annotate-content-right, 0px) + var(--image-annotate-content-bottom, 0px) var(--image-annotate-content-left, 0px); position: absolute; } @media (hover: hover) { @@ -157,7 +158,8 @@ } .image-annotate-edit { display: none; - inset: 0; + inset: var(--image-annotate-content-top, 0px) var(--image-annotate-content-right, 0px) + var(--image-annotate-content-bottom, 0px) var(--image-annotate-content-left, 0px); position: absolute; } .image-annotate-edit-form { diff --git a/test/annotate-image.test.ts b/test/annotate-image.test.ts index cc1be54..4348bdd 100644 --- a/test/annotate-image.test.ts +++ b/test/annotate-image.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import '../src/jquery.annotate.ts'; -import { createTestImage, getInstance, createScaledTestImage } from './setup.ts'; +import { createTestImage, getInstance, createScaledTestImage, createPaddedTestImage } from './setup.ts'; +import { AnnotateImage } from '../src/annotate-image'; import type { AnnotateView } from '../src/annotate-view'; describe('annotateImage — initialization', () => { @@ -822,6 +823,52 @@ describe('auto-scaling — ResizeObserver', () => { expect(inst.scaleX).toBe(1); expect(inst.scaleY).toBe(1); }); + + test('resize callback subtracts content insets from dimensions', () => { + // 400x300 natural, 400x300 content, 10px padding = 420x320 border-box + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const inst = createPaddedTestImage(400, 300, 400, 300, 10, 0, { notes: [note] }); + expect(inst.scaleX).toBe(1); + + // ResizeObserver fires with canvas contentRect (= image border-box). + // Image shrinks to 200x150 content, so canvas contentRect = 220x170 + observeCallback!([{ contentRect: { width: 220, height: 170 } }]); + + expect(inst.scaleX).toBe(0.5); + expect(inst.scaleY).toBeCloseTo(0.5, 5); + }); + + test('deferred rescale subtracts content insets with padding', () => { + const note = { id: '1', top: 100, left: 200, width: 80, height: 60, text: 'test', editable: true }; + const inst = createPaddedTestImage(400, 300, 400, 300, 10, 0, { notes: [note] }); + expect(inst.scaleX).toBe(1); + + // Enter edit mode + inst.add(); + + // Simulate resize — deferred + observeCallback!([{ contentRect: { width: 220, height: 170 } }]); + expect(inst.scaleX).toBe(1); + + // Mock canvas getBoundingClientRect for flush (canvas = image border-box) + inst.canvas.getBoundingClientRect = () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 220, + bottom: 170, + width: 220, + height: 170, + toJSON() { + return this; + }, + }); + + inst.cancelEdit(); + expect(inst.scaleX).toBe(0.5); + expect(inst.scaleY).toBeCloseTo(0.5, 5); + }); }); describe('auto-scaling — scale factor computation', () => { @@ -861,6 +908,128 @@ describe('auto-scaling — scale factor computation', () => { }); }); +describe('padding/border — scale factor computation', () => { + test('scale factors use content dimensions, not border-box, with padding', () => { + const inst = createPaddedTestImage(400, 300, 400, 300, 10, 0); + expect(inst.scaleX).toBe(1); + expect(inst.scaleY).toBe(1); + }); + + test('scale factors use content dimensions, not border-box, with border', () => { + const inst = createPaddedTestImage(400, 300, 400, 300, 0, 5); + expect(inst.scaleX).toBe(1); + expect(inst.scaleY).toBe(1); + }); + + test('scale factors use content dimensions with both padding and border', () => { + const inst = createPaddedTestImage(400, 300, 200, 150, 10, 2); + expect(inst.scaleX).toBe(0.5); + expect(inst.scaleY).toBe(0.5); + }); + + test('toRendered uses correct scale with padding present', () => { + const inst = createPaddedTestImage(400, 300, 200, 150, 10, 2); + const result = inst.toRendered({ top: 100, left: 200, width: 80, height: 60 }); + expect(result).toEqual({ top: 50, left: 100, width: 40, height: 30 }); + }); + + test('zero padding and border behaves like no padding/border', () => { + const inst = createPaddedTestImage(400, 300, 400, 300, 0, 0); + expect(inst.scaleX).toBe(1); + expect(inst.scaleY).toBe(1); + }); + + test('sets content inset CSS vars on canvas when image has padding', () => { + const inst = createPaddedTestImage(400, 300, 400, 300, 10, 0); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-top')).toBe('10px'); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-right')).toBe('10px'); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-bottom')).toBe('10px'); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-left')).toBe('10px'); + }); + + test('sets content inset CSS vars on canvas with padding and border', () => { + const inst = createPaddedTestImage(400, 300, 200, 150, 10, 2); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-top')).toBe('12px'); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-right')).toBe('12px'); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-bottom')).toBe('12px'); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-left')).toBe('12px'); + }); + + test('does not set content inset CSS vars when image has no padding/border', () => { + const inst = createScaledTestImage(400, 300, 400, 300); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-top')).toBe(''); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-right')).toBe(''); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-bottom')).toBe(''); + expect(inst.canvas.style.getPropertyValue('--image-annotate-content-left')).toBe(''); + }); + + test('handles asymmetric padding correctly', () => { + 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.style.paddingTop = '10px'; + img.style.paddingRight = '20px'; + img.style.paddingBottom = '5px'; + img.style.paddingLeft = '15px'; + // content 400x300, border-box = 400+35 x 300+15 = 435x315 + img.getBoundingClientRect = () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 435, + bottom: 315, + width: 435, + height: 315, + toJSON() { + return this; + }, + }); + document.body.appendChild(img); + const inst = new AnnotateImage(img, { editable: true, notes: [] }); + expect(inst.scaleX).toBe(1); + expect(inst.scaleY).toBe(1); + expect(inst.contentInset).toEqual({ top: 10, right: 20, bottom: 5, left: 15 }); + }); + + test('handles padding with no border (border style unset)', () => { + 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.style.paddingTop = '10px'; + img.style.paddingRight = '10px'; + img.style.paddingBottom = '10px'; + img.style.paddingLeft = '10px'; + // No border set — borderTopWidth etc. will be empty string in jsdom + img.getBoundingClientRect = () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 420, + bottom: 320, + width: 420, + height: 320, + toJSON() { + return this; + }, + }); + document.body.appendChild(img); + const inst = new AnnotateImage(img, { editable: true, notes: [] }); + expect(inst.scaleX).toBe(1); + expect(inst.scaleY).toBe(1); + expect(inst.contentInset).toEqual({ top: 10, right: 10, bottom: 10, left: 10 }); + }); +}); + describe('toRendered / toNatural coordinate conversion', () => { test('toRendered scales natural coordinates by scale factors', () => { const inst = createScaledTestImage(400, 300, 200, 150); diff --git a/test/annotate-view.test.ts b/test/annotate-view.test.ts index 6a63038..e0e4acb 100644 --- a/test/annotate-view.test.ts +++ b/test/annotate-view.test.ts @@ -46,6 +46,25 @@ describe('annotateView — rendering', () => { expect(view.tooltip.style.display).toBe('none'); }); + + test('empty text shows placeholder in tooltip', () => { + const { view } = createImageWithNote({ text: '' }); + + expect(view.tooltip.textContent).toBe('(no annotation)'); + }); + + test('whitespace-only text shows placeholder in tooltip', () => { + const { view } = createImageWithNote({ text: ' ' }); + + expect(view.tooltip.textContent).toBe('(no annotation)'); + }); + + test('note data keeps empty text for export even when tooltip shows placeholder', () => { + const { inst } = createImageWithNote({ text: '' }); + + const exported = inst.getNotes(); + expect(exported[0].text).toBe(''); + }); }); describe('annotateView — positioning', () => { @@ -374,4 +393,20 @@ describe('auto-scaling — view positioning', () => { expect(view.note.width).toBe(80); expect(view.note.height).toBe(60); }); + + test('resetPosition with empty text shows placeholder in tooltip', () => { + 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); + + const fakeEditable = { + area: document.createElement('div'), + note: { ...note }, + }; + + view.resetPosition(fakeEditable, ''); + + expect(view.tooltip.textContent).toBe('(no annotation)'); + expect(view.note.text).toBe(''); + }); }); diff --git a/test/setup.ts b/test/setup.ts index 9c91725..00154f1 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -84,3 +84,57 @@ export function createScaledTestImage( document.body.appendChild(img); return new AnnotateImage(img, { editable: true, notes: [], ...options }); } + +/** + * Creates an image with padding and border, mocking dimensions accordingly. + * The getBoundingClientRect returns border-box dimensions (content + padding + border). + * getComputedStyle returns the individual padding/border values. + */ +export function createPaddedTestImage( + naturalW: number, + naturalH: number, + contentW: number, + contentH: number, + padding: number, + border: 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 }); + + // Set individual padding/border properties so getComputedStyle returns them + img.style.paddingTop = `${padding}px`; + img.style.paddingRight = `${padding}px`; + img.style.paddingBottom = `${padding}px`; + img.style.paddingLeft = `${padding}px`; + img.style.borderTopWidth = `${border}px`; + img.style.borderRightWidth = `${border}px`; + img.style.borderBottomWidth = `${border}px`; + img.style.borderLeftWidth = `${border}px`; + img.style.borderStyle = 'solid'; + + // getBoundingClientRect returns border-box: content + padding + border + const borderBoxW = contentW + padding * 2 + border * 2; + const borderBoxH = contentH + padding * 2 + border * 2; + img.getBoundingClientRect = () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: borderBoxW, + bottom: borderBoxH, + width: borderBoxW, + height: borderBoxH, + toJSON() { + return this; + }, + }); + + document.body.appendChild(img); + return new AnnotateImage(img, { editable: true, notes: [], ...options }); +}