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.
+

+
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 });
+}