Implement Paperjet updates
This commit is contained in:
parent
5e19b78259
commit
4569dea864
19 changed files with 1622 additions and 151 deletions
163
frontend/package-lock.json
generated
163
frontend/package-lock.json
generated
|
|
@ -30,6 +30,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/fabric": "^5.3.11",
|
||||
|
|
@ -510,16 +511,6 @@
|
|||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
|
|
@ -1094,6 +1085,7 @@
|
|||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
|
|
@ -1717,6 +1709,66 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
||||
|
|
@ -1763,6 +1815,26 @@
|
|||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
|
|
@ -1822,12 +1894,20 @@
|
|||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
|
@ -2352,6 +2432,29 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
|
|
@ -2898,6 +3001,13 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.371",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
|
||||
|
|
@ -4161,6 +4271,16 @@
|
|||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -4585,6 +4705,21 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
|
|
@ -4643,6 +4778,13 @@
|
|||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
||||
|
|
@ -5133,6 +5275,7 @@
|
|||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/fabric": "^5.3.11",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { useEditorStore } from '../store';
|
|||
import { getTool } from '../../../lib/annotations/registry';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
import { screenRectToPdf } from '../../../lib/coords';
|
||||
import type { Annotation, SignatureAnnotation } from '../../../lib/annotations/types';
|
||||
import { TextFormatToolbar } from '../toolbar/TextFormatToolbar';
|
||||
import { signatureTextBounds } from '../tools/signatureGeometry';
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
pageNumber: number;
|
||||
|
|
@ -50,13 +52,28 @@ export function AnnotationLayer({ pageNumber, width, height, viewportParams }: A
|
|||
if (!object?.id) return;
|
||||
const annotation = useEditorStore.getState().annotations.find((item) => item.id === object.id);
|
||||
if (!annotation) return;
|
||||
const bounds = object.getBoundingRect();
|
||||
useEditorStore.getState().updateAnnotation(object.id, {
|
||||
const bounds =
|
||||
annotation.type === 'signature' && annotation.props.mode === 'type'
|
||||
? signatureTextBounds(object)
|
||||
: object.getBoundingRect();
|
||||
const updates: Partial<Annotation> = {
|
||||
rect: screenRectToPdf(
|
||||
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
|
||||
latestViewportParams.current,
|
||||
),
|
||||
});
|
||||
};
|
||||
if (annotation.type === 'signature' && annotation.props.mode === 'type') {
|
||||
const textObject = object as fabric.Text;
|
||||
const screenFontSize = textObject.fontSize * (textObject.scaleY || 1);
|
||||
if (Number.isFinite(screenFontSize) && latestViewportParams.current.scale > 0) {
|
||||
const nextFontSize = screenFontSize / latestViewportParams.current.scale;
|
||||
updates.props = {
|
||||
...(annotation as SignatureAnnotation).props,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
useEditorStore.getState().updateAnnotation(object.id, updates);
|
||||
});
|
||||
|
||||
const updateSelection = () => {
|
||||
|
|
|
|||
145
frontend/src/features/editor/toolbar/TextFormatToolbar.test.tsx
Normal file
145
frontend/src/features/editor/toolbar/TextFormatToolbar.test.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TextFormatToolbar } from './TextFormatToolbar';
|
||||
import { useEditorStore } from '../store';
|
||||
import type { TextAnnotation } from '../../../lib/annotations/types';
|
||||
import type { Canvas } from 'fabric';
|
||||
|
||||
const annotation: TextAnnotation = {
|
||||
id: 'text-1',
|
||||
page: 0,
|
||||
type: 'text',
|
||||
rect: { x: 20, y: 20, width: 120, height: 32 },
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
props: {
|
||||
text: 'Test text',
|
||||
fontFamily: 'Liberation Sans',
|
||||
fontSize: 14,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
bold: false,
|
||||
italic: false,
|
||||
lineHeight: 1.2,
|
||||
highlightColor: null,
|
||||
},
|
||||
};
|
||||
|
||||
const viewportParams = {
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
canonicalWidth: 612,
|
||||
canonicalHeight: 792,
|
||||
};
|
||||
|
||||
function makeCanvas(activeObject: Record<string, unknown> | null = null) {
|
||||
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
||||
return {
|
||||
getActiveObject: vi.fn(() => activeObject),
|
||||
requestRenderAll: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
const handlers = listeners.get(event) ?? new Set();
|
||||
handlers.add(handler);
|
||||
listeners.set(event, handlers);
|
||||
}),
|
||||
off: vi.fn((event: string, handler?: (...args: unknown[]) => void) => {
|
||||
if (!handler) {
|
||||
listeners.delete(event);
|
||||
return;
|
||||
}
|
||||
listeners.get(event)?.delete(handler);
|
||||
}),
|
||||
emit: (event: string) => listeners.get(event)?.forEach((handler) => handler()),
|
||||
} as unknown as Canvas & { emit: (event: string) => void };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useEditorStore.getState().setAnnotations([]);
|
||||
useEditorStore.getState().setDraftAnnotation(null);
|
||||
});
|
||||
|
||||
describe('TextFormatToolbar', () => {
|
||||
it('does not show stale bold or italic state without the matching active text object', () => {
|
||||
useEditorStore.getState().setAnnotations([
|
||||
{
|
||||
...annotation,
|
||||
props: { ...annotation.props, bold: true, italic: true },
|
||||
},
|
||||
]);
|
||||
const canvas = makeCanvas();
|
||||
|
||||
render(
|
||||
<TextFormatToolbar
|
||||
annotationId={annotation.id}
|
||||
viewportParams={viewportParams}
|
||||
canvas={canvas}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('Bold')).not.toHaveClass('bg-blue-100');
|
||||
expect(screen.getByTitle('Italic')).not.toHaveClass('bg-blue-100');
|
||||
});
|
||||
|
||||
it('persists selected-range styles without replacing the annotation base props', () => {
|
||||
useEditorStore.getState().setAnnotations([annotation]);
|
||||
const activeObject: Record<string, unknown> = {
|
||||
id: annotation.id,
|
||||
text: annotation.props.text,
|
||||
isEditing: true,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 4,
|
||||
styles: {},
|
||||
setSelectionStyles: vi.fn((styles: Record<string, unknown>) => {
|
||||
activeObject.styles = { '0': { '0': styles } };
|
||||
}),
|
||||
set: vi.fn(),
|
||||
initDimensions: vi.fn(),
|
||||
setCoords: vi.fn(),
|
||||
};
|
||||
const canvas = makeCanvas(activeObject);
|
||||
|
||||
render(
|
||||
<TextFormatToolbar
|
||||
annotationId={annotation.id}
|
||||
viewportParams={viewportParams}
|
||||
canvas={canvas}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Bold'));
|
||||
|
||||
const updated = useEditorStore.getState().annotations[0] as TextAnnotation;
|
||||
expect(activeObject.setSelectionStyles).toHaveBeenCalledWith({ fontWeight: 'bold' });
|
||||
expect(updated.props.bold).toBe(false);
|
||||
expect(updated.props.styles).toEqual({ '0': { '0': { fontWeight: 'bold' } } });
|
||||
});
|
||||
|
||||
it('updates the active state when Fabric reports a text selection change', () => {
|
||||
useEditorStore.getState().setAnnotations([annotation]);
|
||||
const activeObject: Record<string, unknown> = {
|
||||
id: annotation.id,
|
||||
text: annotation.props.text,
|
||||
isEditing: true,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 4,
|
||||
styles: {},
|
||||
getSelectionStyles: vi.fn(() => [{ fontStyle: 'italic' }]),
|
||||
};
|
||||
const canvas = makeCanvas(activeObject);
|
||||
|
||||
render(
|
||||
<TextFormatToolbar
|
||||
annotationId={annotation.id}
|
||||
viewportParams={viewportParams}
|
||||
canvas={canvas}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('Italic')).toHaveClass('bg-blue-100');
|
||||
activeObject.getSelectionStyles = vi.fn(() => [{ fontStyle: 'normal' }]);
|
||||
act(() => canvas.emit('text:selection:changed'));
|
||||
expect(screen.getByTitle('Italic')).not.toHaveClass('bg-blue-100');
|
||||
});
|
||||
});
|
||||
|
|
@ -22,6 +22,55 @@ interface TextFormatToolbarProps {
|
|||
canvas: Canvas;
|
||||
}
|
||||
|
||||
type ToolbarTextObject = fabric.Textbox & {
|
||||
id?: string;
|
||||
customHeight?: number;
|
||||
styles?: Record<string, Record<string, Record<string, unknown>>>;
|
||||
};
|
||||
|
||||
function cloneInlineStyles(styles: unknown): Record<string, unknown> | null {
|
||||
if (!styles || typeof styles !== 'object' || Object.keys(styles).length === 0) return null;
|
||||
return JSON.parse(JSON.stringify(styles)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isBoldValue(value: unknown) {
|
||||
return value === 'bold' || value === 700 || value === '700';
|
||||
}
|
||||
|
||||
function isItalicValue(value: unknown) {
|
||||
return value === 'italic' || value === 'oblique';
|
||||
}
|
||||
|
||||
function hasSelectedRange(object: ToolbarTextObject) {
|
||||
return Boolean(
|
||||
object.isEditing &&
|
||||
typeof object.selectionStart === 'number' &&
|
||||
typeof object.selectionEnd === 'number' &&
|
||||
object.selectionEnd > object.selectionStart,
|
||||
);
|
||||
}
|
||||
|
||||
function baseStyleValue(object: ToolbarTextObject, property: string): unknown {
|
||||
const fabricObject = object as unknown as {
|
||||
get?: (name: string) => unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
return typeof fabricObject.get === 'function' ? fabricObject.get(property) : fabricObject[property];
|
||||
}
|
||||
|
||||
function selectedStyleValue(object: ToolbarTextObject, property: string): unknown {
|
||||
if (!object.isEditing || typeof object.getSelectionStyles !== 'function') {
|
||||
return baseStyleValue(object, property);
|
||||
}
|
||||
const start = object.selectionStart ?? 0;
|
||||
const end = object.selectionEnd ?? start;
|
||||
const styles = object.getSelectionStyles(start, Math.max(start + 1, end), true) as Array<Record<string, unknown>>;
|
||||
if (!styles.length) return baseStyleValue(object, property);
|
||||
const values = styles.map((style) => style[property]);
|
||||
if (values.every((value) => value === values[0]) && values[0] !== undefined) return values[0];
|
||||
return baseStyleValue(object, property);
|
||||
}
|
||||
|
||||
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New'];
|
||||
const SIZES = [6, 7, 8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
|
||||
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
|
||||
|
|
@ -33,6 +82,21 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
|
|||
const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [, setStyleRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setStyleRevision((revision) => revision + 1);
|
||||
const events = [
|
||||
'text:selection:changed',
|
||||
'text:changed',
|
||||
'selection:created',
|
||||
'selection:updated',
|
||||
'selection:cleared',
|
||||
] as const;
|
||||
events.forEach((event) => canvas.on(event, refresh));
|
||||
return () => events.forEach((event) => canvas.off(event, refresh));
|
||||
}, [canvas]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (toolbarRef.current && !toolbarRef.current.contains(event.target as Node)) {
|
||||
|
|
@ -50,92 +114,69 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
|
|||
const textAnn = annotation as TextAnnotation;
|
||||
const props = textAnn.props;
|
||||
|
||||
const activeObject = canvas.getActiveObject() as ToolbarTextObject | undefined;
|
||||
const activeTextObject = activeObject?.id === annotationId ? activeObject : undefined;
|
||||
const boldActive = Boolean(activeTextObject && isBoldValue(selectedStyleValue(activeTextObject, 'fontWeight')));
|
||||
const italicActive = Boolean(activeTextObject && isItalicValue(selectedStyleValue(activeTextObject, 'fontStyle')));
|
||||
|
||||
const pt = pdfRectToScreen(textAnn.rect, viewportParams);
|
||||
const top = pt.y - 48; // 48px above
|
||||
const left = pt.x;
|
||||
|
||||
const applyStyle = (styleName: string, value: unknown, globalPropName: keyof TextProps, globalValue?: unknown) => {
|
||||
const activeObj = canvas.getActiveObject() as (fabric.Textbox & { id?: string; customHeight?: number }) | undefined;
|
||||
if (activeObj) {
|
||||
if (activeObj && activeObj.id === annotationId) {
|
||||
const isStructural = styleName === 'fontSize' || styleName === 'fontFamily';
|
||||
|
||||
if (isStructural) {
|
||||
// Structural properties MUST be applied to the base object. Fabric 7's bounding box calculations
|
||||
// frequently fail when inline styles are used for size/font.
|
||||
activeObj.set(styleName, value);
|
||||
|
||||
// Obliterate any inline styles for this property so the base property strictly applies to all text
|
||||
if (activeObj.styles) {
|
||||
for (const line in activeObj.styles) {
|
||||
for (const char in activeObj.styles[line]) {
|
||||
if (activeObj.styles[line][char]) {
|
||||
delete activeObj.styles[line][char][styleName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the box is empty and we are currently editing it, Fabric's invisible cursor
|
||||
// cache will still stubbornly hold the old size unless we violently wipe it.
|
||||
if (activeObj.isEditing && !activeObj.text) {
|
||||
activeObj.styles = {};
|
||||
if (activeObj.hiddenTextarea) {
|
||||
if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`;
|
||||
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Cosmetic properties (bold, italic, color) work fine with inline styles
|
||||
if (activeObj.isEditing) {
|
||||
activeObj.setSelectionStyles({ [styleName]: value });
|
||||
if (!activeObj.text) {
|
||||
activeObj.set(styleName, value);
|
||||
}
|
||||
} else {
|
||||
// If NOT editing, they selected the whole box. Update the base property.
|
||||
activeObj.set(styleName, value);
|
||||
|
||||
// Clear any inline styles for this property so the base property actually takes effect!
|
||||
if (activeObj.styles) {
|
||||
for (const line in activeObj.styles) {
|
||||
for (const char in activeObj.styles[line]) {
|
||||
if (activeObj.styles[line][char]) {
|
||||
delete (activeObj.styles[line][char] as Record<string, unknown>)[styleName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const activeObj = canvas.getActiveObject() as ToolbarTextObject | undefined;
|
||||
if (!activeObj || activeObj.id !== annotationId) return;
|
||||
|
||||
const isStructural = styleName === 'fontSize' || styleName === 'fontFamily';
|
||||
const rangeSelected = hasSelectedRange(activeObj);
|
||||
|
||||
if (rangeSelected && !isStructural) {
|
||||
activeObj.setSelectionStyles({ [styleName]: value });
|
||||
} else {
|
||||
// Structural properties and non-editing changes apply to the base object.
|
||||
// Inline values for the same property are removed so the base value is
|
||||
// not shadowed after the object is reloaded from the store.
|
||||
activeObj.set(styleName, value);
|
||||
if (activeObj.styles) {
|
||||
for (const line of Object.values(activeObj.styles)) {
|
||||
for (const character of Object.values(line)) {
|
||||
if (character) delete (character as unknown as Record<string, unknown>)[styleName];
|
||||
}
|
||||
}
|
||||
|
||||
// Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw!
|
||||
activeObj.dirty = true;
|
||||
if ('_forceClearCache' in activeObj) {
|
||||
(activeObj as typeof activeObj & { _forceClearCache?: boolean })._forceClearCache = true;
|
||||
}
|
||||
if (activeObj.isEditing && !activeObj.text) {
|
||||
activeObj.styles = {};
|
||||
if (activeObj.hiddenTextarea) {
|
||||
if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`;
|
||||
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value);
|
||||
}
|
||||
|
||||
// Remove manual height constraint so the box can grow with the new font size
|
||||
delete activeObj.customHeight;
|
||||
if (activeObj.initDimensions) activeObj.initDimensions();
|
||||
activeObj.setCoords();
|
||||
canvas.requestRenderAll();
|
||||
}
|
||||
}
|
||||
|
||||
const finalGlobalValue = globalValue !== undefined ? globalValue : value;
|
||||
const newProps = { [globalPropName]: finalGlobalValue } as Partial<TextProps>;
|
||||
setDefaultTextProps(newProps);
|
||||
|
||||
// Always update store so the toolbar displays the new value
|
||||
if (!isDraft) {
|
||||
updateAnnotation(annotationId, { props: { ...props, ...newProps } });
|
||||
} else {
|
||||
useEditorStore.getState().setDraftAnnotation({
|
||||
...textAnn,
|
||||
props: { ...props, ...newProps }
|
||||
});
|
||||
|
||||
activeObj.dirty = true;
|
||||
if ('_forceClearCache' in activeObj) {
|
||||
(activeObj as ToolbarTextObject & { _forceClearCache?: boolean })._forceClearCache = true;
|
||||
}
|
||||
delete activeObj.customHeight;
|
||||
activeObj.initDimensions();
|
||||
activeObj.setCoords();
|
||||
canvas.requestRenderAll();
|
||||
|
||||
const defaultProps: Partial<TextProps> = {
|
||||
[globalPropName]: globalValue !== undefined ? globalValue : value,
|
||||
} as Partial<TextProps>;
|
||||
const newProps: Partial<TextProps> = {
|
||||
...(!rangeSelected || isStructural ? defaultProps : {}),
|
||||
...(rangeSelected && !isStructural ? { styles: cloneInlineStyles(activeObj.styles) } : {}),
|
||||
} as Partial<TextProps>;
|
||||
setDefaultTextProps(defaultProps);
|
||||
|
||||
if (!isDraft) {
|
||||
updateAnnotation(annotationId, { props: { ...props, ...newProps } });
|
||||
} else {
|
||||
useEditorStore.getState().setDraftAnnotation({ ...textAnn, props: { ...props, ...newProps } });
|
||||
}
|
||||
setStyleRevision((revision) => revision + 1);
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
|
|
@ -231,15 +272,15 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
|
|||
|
||||
{/* Bold / Italic */}
|
||||
<button
|
||||
onClick={() => applyStyle('fontWeight', props.bold ? 'normal' : 'bold', 'bold')}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${props.bold ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
||||
onClick={() => applyStyle('fontWeight', boldActive ? 'normal' : 'bold', 'bold', !boldActive)}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${boldActive ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyStyle('fontStyle', props.italic ? 'normal' : 'italic', 'italic')}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${props.italic ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
||||
onClick={() => applyStyle('fontStyle', italicActive ? 'normal' : 'italic', 'italic', !italicActive)}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${italicActive ? 'bg-blue-100 text-blue-700' : 'hover:bg-neutral-100 text-neutral-700'}`}
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
|
|
@ -306,7 +347,7 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
|
|||
{HIGHLIGHTS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => { applyStyle('textBackgroundColor', c === 'transparent' ? undefined : c, 'highlightColor'); setActiveDropdown(null); }}
|
||||
onClick={() => { applyStyle('textBackgroundColor', c === 'transparent' ? undefined : c, 'highlightColor', c === 'transparent' ? null : c); setActiveDropdown(null); }}
|
||||
className={`w-6 h-6 rounded-sm border ${props.highlightColor === c || (c === 'transparent' && !props.highlightColor) ? 'border-blue-500 scale-110' : 'border-neutral-300 hover:scale-110'}`}
|
||||
style={{
|
||||
backgroundColor: c === 'transparent' ? '#ffffff' : c,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import { useEditorStore } from '../store';
|
||||
import type { ToolHandler } from '../../../lib/annotations/registry';
|
||||
import type { Annotation, DrawAnnotation, DrawProps } from '../../../lib/annotations/types';
|
||||
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||
import {
|
||||
pdfRectToScreen,
|
||||
screenRectToPdf,
|
||||
screenStrokeWidthToPdf,
|
||||
storedStrokeWidthToScreen,
|
||||
} from '../../../lib/coords';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
|
||||
type FabricAnnotationObject = fabric.FabricObject & {
|
||||
|
|
@ -12,6 +17,8 @@ type FabricAnnotationObject = fabric.FabricObject & {
|
|||
annotationProps?: DrawProps;
|
||||
};
|
||||
|
||||
const DRAW_STROKE_WIDTH = 4;
|
||||
|
||||
export const DrawTool: ToolHandler = {
|
||||
name: 'draw',
|
||||
|
||||
|
|
@ -19,7 +26,7 @@ export const DrawTool: ToolHandler = {
|
|||
canvas.isDrawingMode = true;
|
||||
const brush = new fabric.PencilBrush(canvas);
|
||||
brush.color = '#000000';
|
||||
brush.width = 4;
|
||||
brush.width = DRAW_STROKE_WIDTH;
|
||||
canvas.freeDrawingBrush = brush;
|
||||
},
|
||||
|
||||
|
|
@ -37,6 +44,7 @@ export const DrawTool: ToolHandler = {
|
|||
borderColor: '#3b82f6',
|
||||
cornerSize: 8,
|
||||
padding: 5,
|
||||
strokeUniform: true,
|
||||
});
|
||||
|
||||
const annotatedPath = pathObj as FabricAnnotationObject;
|
||||
|
|
@ -79,7 +87,8 @@ export const DrawTool: ToolHandler = {
|
|||
]),
|
||||
svgPath: pathStr as string,
|
||||
strokeColor: pathObj.stroke as string,
|
||||
strokeWidth: pathObj.strokeWidth,
|
||||
strokeWidth: screenStrokeWidthToPdf(pathObj.strokeWidth ?? DRAW_STROKE_WIDTH, vp),
|
||||
strokeWidthUnit: 'pdf',
|
||||
opacity: pathObj.opacity,
|
||||
};
|
||||
annotatedPath.annotationProps = props;
|
||||
|
|
@ -114,10 +123,11 @@ export const DrawTool: ToolHandler = {
|
|||
pathObj = new fabric.Path(props.svgPath, {
|
||||
fill: '',
|
||||
stroke: props.strokeColor || '#000000',
|
||||
strokeWidth: props.strokeWidth || 4,
|
||||
strokeWidth: storedStrokeWidthToScreen(props.strokeWidth ?? DRAW_STROKE_WIDTH, props.strokeWidthUnit, vp),
|
||||
strokeLineCap: 'round',
|
||||
strokeLineJoin: 'round',
|
||||
opacity: props.opacity || 1,
|
||||
strokeUniform: true,
|
||||
});
|
||||
|
||||
// Fabric Paths have internal boundaries. We need to scale it to fit the rect.
|
||||
|
|
@ -140,7 +150,8 @@ export const DrawTool: ToolHandler = {
|
|||
width: screenRect.width,
|
||||
height: screenRect.height,
|
||||
stroke: props.strokeColor || '#000000',
|
||||
strokeWidth: props.strokeWidth || 4,
|
||||
strokeWidth: storedStrokeWidthToScreen(props.strokeWidth ?? DRAW_STROKE_WIDTH, props.strokeWidthUnit, vp),
|
||||
strokeUniform: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +166,7 @@ export const DrawTool: ToolHandler = {
|
|||
borderColor: '#3b82f6',
|
||||
cornerSize: 8,
|
||||
padding: 5,
|
||||
strokeUniform: true,
|
||||
});
|
||||
|
||||
canvas.add(pathObj);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,18 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import { useEditorStore } from '../store';
|
||||
import type { ToolHandler } from '../../../lib/annotations/registry';
|
||||
import type { Annotation, ShapeAnnotation, ShapeProps } from '../../../lib/annotations/types';
|
||||
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
|
||||
import {
|
||||
pdfRectToScreen,
|
||||
screenRectToPdf,
|
||||
screenStrokeWidthToPdf,
|
||||
storedStrokeWidthToScreen,
|
||||
} from '../../../lib/coords';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
|
||||
type PointerEvent = fabric.TPointerEventInfo;
|
||||
type Draft = { object: fabric.FabricObject; start: { x: number; y: number }; kind: ShapeProps['kind'] };
|
||||
const drafts = new WeakMap<fabric.Canvas, Draft>();
|
||||
const SHAPE_STROKE_WIDTH = 2;
|
||||
|
||||
function pointer(event: PointerEvent, canvas: fabric.Canvas) {
|
||||
return event.scenePoint ?? canvas.getScenePoint(event.e);
|
||||
|
|
@ -23,6 +29,7 @@ function style(object: fabric.FabricObject) {
|
|||
cornerStrokeColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
cornerSize: 8,
|
||||
strokeUniform: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -54,14 +61,22 @@ function createArrow(start: { x: number; y: number }, end: { x: number; y: numbe
|
|||
top: geometry.top,
|
||||
fill: 'transparent',
|
||||
stroke: '#111827',
|
||||
strokeWidth: 2,
|
||||
strokeWidth: SHAPE_STROKE_WIDTH,
|
||||
strokeUniform: true,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
});
|
||||
}
|
||||
|
||||
function createObject(kind: ShapeProps['kind'], start: { x: number; y: number }) {
|
||||
const common = { left: start.x, top: start.y, fill: 'transparent', stroke: '#111827', strokeWidth: 2 };
|
||||
const common = {
|
||||
left: start.x,
|
||||
top: start.y,
|
||||
fill: 'transparent',
|
||||
stroke: '#111827',
|
||||
strokeWidth: SHAPE_STROKE_WIDTH,
|
||||
strokeUniform: true,
|
||||
};
|
||||
if (kind === 'ellipse') return new fabric.Ellipse({ ...common, rx: 0, ry: 0 });
|
||||
if (kind === 'arrow') return createArrow(start, start);
|
||||
if (kind === 'line') return new fabric.Line([0, 0, 0, 0], common);
|
||||
|
|
@ -137,7 +152,8 @@ export const ShapeTool: ToolHandler = {
|
|||
kind: draft.kind,
|
||||
strokeColor: '#111827',
|
||||
fillColor: 'transparent',
|
||||
strokeWidth: 2,
|
||||
strokeWidth: screenStrokeWidthToPdf(SHAPE_STROKE_WIDTH, vp),
|
||||
strokeWidthUnit: 'pdf',
|
||||
};
|
||||
if (draft.kind === 'line' || draft.kind === 'arrow') {
|
||||
const width = Math.max(bounds.width, 1);
|
||||
|
|
@ -177,7 +193,8 @@ export const ShapeTool: ToolHandler = {
|
|||
const common = {
|
||||
fill: props.fillColor === 'transparent' ? 'transparent' : props.fillColor,
|
||||
stroke: props.strokeColor,
|
||||
strokeWidth: props.strokeWidth,
|
||||
strokeWidth: storedStrokeWidthToScreen(props.strokeWidth, props.strokeWidthUnit, vp),
|
||||
strokeUniform: true,
|
||||
};
|
||||
let object: fabric.FabricObject;
|
||||
if (props.kind === 'ellipse') {
|
||||
|
|
|
|||
23
frontend/src/features/editor/tools/SignatureTool.test.ts
Normal file
23
frontend/src/features/editor/tools/SignatureTool.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type * as fabric from 'fabric';
|
||||
import { signatureTextBounds } from './signatureGeometry';
|
||||
|
||||
describe('signatureTextBounds', () => {
|
||||
it('uses scaled text geometry without Fabric padding or phantom stroke width', () => {
|
||||
const object = {
|
||||
width: 182.88,
|
||||
height: 54.24,
|
||||
scaleX: 1.5,
|
||||
scaleY: 2,
|
||||
padding: 5,
|
||||
strokeWidth: 1,
|
||||
getCenterPoint: () => ({ x: 300, y: 150 }),
|
||||
} as unknown as fabric.FabricObject;
|
||||
|
||||
const bounds = signatureTextBounds(object);
|
||||
expect(bounds.left).toBeCloseTo(162.84);
|
||||
expect(bounds.top).toBeCloseTo(95.76);
|
||||
expect(bounds.width).toBeCloseTo(274.32);
|
||||
expect(bounds.height).toBeCloseTo(108.48);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import type { ToolHandler } from '../../../lib/annotations/registry';
|
|||
import type { Annotation, SignatureAnnotation, SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types';
|
||||
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
import { signatureTextBounds } from './signatureGeometry';
|
||||
|
||||
type SignatureProps = SignatureDrawProps | SignatureTypeProps;
|
||||
type FabricSignatureObject = fabric.FabricObject & {
|
||||
|
|
@ -16,6 +17,7 @@ type FabricSignatureObject = fabric.FabricObject & {
|
|||
let previewObj: fabric.FabricObject | null = null;
|
||||
let currentPreviewCanvas: fabric.Canvas | null = null;
|
||||
let isCreatingPreview = false;
|
||||
const SIGNATURE_FONT_SIZE = 48;
|
||||
|
||||
const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: SignatureProps) => {
|
||||
isCreatingPreview = true;
|
||||
|
|
@ -34,9 +36,10 @@ const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: S
|
|||
previewObj = null;
|
||||
}
|
||||
} else {
|
||||
const fontSize = props.fontSize ?? SIGNATURE_FONT_SIZE;
|
||||
previewObj = new fabric.Text(props.text, {
|
||||
fontFamily: props.fontFamily,
|
||||
fontSize: 48 * vp.scale,
|
||||
fontSize: fontSize * vp.scale,
|
||||
fill: props.color,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
|
|
@ -141,7 +144,7 @@ export const SignatureTool: ToolHandler = {
|
|||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
fontFamily: typeProps.fontFamily,
|
||||
fontSize: 48 * vp.scale,
|
||||
fontSize: (typeProps.fontSize ?? SIGNATURE_FONT_SIZE) * vp.scale,
|
||||
fill: typeProps.color,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
|
|
@ -159,16 +162,19 @@ export const SignatureTool: ToolHandler = {
|
|||
padding: 5,
|
||||
});
|
||||
|
||||
const annotationProps: SignatureProps = props.mode === 'type'
|
||||
? { ...props, fontSize: props.fontSize ?? SIGNATURE_FONT_SIZE }
|
||||
: props;
|
||||
const annotatedObject = fabricObj as FabricSignatureObject;
|
||||
annotatedObject.annotationType = 'signature';
|
||||
annotatedObject.annotationProps = props;
|
||||
annotatedObject.annotationProps = annotationProps;
|
||||
|
||||
canvas.add(fabricObj);
|
||||
canvas.setActiveObject(fabricObj);
|
||||
canvas.requestRenderAll();
|
||||
|
||||
// Convert to canonical space
|
||||
const bounds = fabricObj.getBoundingRect();
|
||||
const bounds = props.mode === 'type' ? signatureTextBounds(fabricObj) : fabricObj.getBoundingRect();
|
||||
const pdfRect = screenRectToPdf({
|
||||
x: bounds.left,
|
||||
y: bounds.top,
|
||||
|
|
@ -183,7 +189,7 @@ export const SignatureTool: ToolHandler = {
|
|||
rect: pdfRect,
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
props: props,
|
||||
props: annotationProps,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
|
@ -227,6 +233,7 @@ export const SignatureTool: ToolHandler = {
|
|||
originX: 'left',
|
||||
originY: 'top',
|
||||
fontFamily: typeProps.fontFamily,
|
||||
fontSize: (typeProps.fontSize ?? SIGNATURE_FONT_SIZE) * vp.scale,
|
||||
fill: typeProps.color,
|
||||
});
|
||||
// Set scale manually to match the saved rect
|
||||
|
|
|
|||
13
frontend/src/features/editor/tools/signatureGeometry.ts
Normal file
13
frontend/src/features/editor/tools/signatureGeometry.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type * as fabric from 'fabric';
|
||||
|
||||
export function signatureTextBounds(object: fabric.FabricObject) {
|
||||
const center = object.getCenterPoint();
|
||||
const width = (object.width || 0) * Math.abs(object.scaleX || 1);
|
||||
const height = (object.height || 0) * Math.abs(object.scaleY || 1);
|
||||
return {
|
||||
left: center.x - width / 2,
|
||||
top: center.y - height / 2,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
|
@ -47,7 +47,10 @@ export interface DrawProps {
|
|||
paths: [number, number][];
|
||||
svgPath?: string;
|
||||
strokeColor: string;
|
||||
/** Canonical PDF-point width for new annotations. */
|
||||
strokeWidth: number;
|
||||
/** Omitted on legacy records whose width was stored in screen pixels. */
|
||||
strokeWidthUnit?: 'pdf' | 'screen';
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +70,8 @@ export interface SignatureTypeProps {
|
|||
text: string;
|
||||
fontFamily: string;
|
||||
color: string;
|
||||
/** Canonical PDF-point font size for new typed signatures. */
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
export interface SignatureAnnotation extends AnnotationBase {
|
||||
|
|
@ -99,7 +104,10 @@ export interface ShapeProps {
|
|||
kind: 'rect' | 'ellipse' | 'line' | 'arrow';
|
||||
strokeColor: string;
|
||||
fillColor: string;
|
||||
/** Canonical PDF-point width for new annotations. */
|
||||
strokeWidth: number;
|
||||
/** Omitted on legacy records whose width was stored in screen pixels. */
|
||||
strokeWidthUnit?: 'pdf' | 'screen';
|
||||
/** Normalized endpoints, retained for line/arrow direction. */
|
||||
start?: [number, number];
|
||||
end?: [number, number];
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import {
|
|||
screenToPdf,
|
||||
pdfRectToScreen,
|
||||
screenRectToPdf,
|
||||
pdfStrokeWidthToScreen,
|
||||
screenStrokeWidthToPdf,
|
||||
storedStrokeWidthToScreen,
|
||||
} from './coords';
|
||||
import type {
|
||||
ViewportParams,
|
||||
|
|
@ -104,4 +107,16 @@ describe('Coordinate Transforms', () => {
|
|||
);
|
||||
expect(retina).toEqual(standard);
|
||||
});
|
||||
|
||||
it('round-trips canonical stroke widths at every supported zoom', () => {
|
||||
for (const scale of [0.5, 1, 2]) {
|
||||
const vp: ViewportParams = { scale, rotation: 0, canonicalWidth: W, canonicalHeight: H };
|
||||
const pdfWidth = screenStrokeWidthToPdf(4, vp);
|
||||
|
||||
expect(pdfWidth).toBeCloseTo(4 / scale, 6);
|
||||
expect(pdfStrokeWidthToScreen(pdfWidth, vp)).toBeCloseTo(4, 6);
|
||||
expect(storedStrokeWidthToScreen(pdfWidth, 'pdf', vp)).toBeCloseTo(4, 6);
|
||||
expect(storedStrokeWidthToScreen(4, undefined, vp)).toBe(4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,27 @@ export interface ViewportParams {
|
|||
dpr?: number; // Backing-store density; never part of CSS geometry
|
||||
}
|
||||
|
||||
export type StrokeWidthUnit = 'pdf' | 'screen';
|
||||
|
||||
/** Convert a Fabric/CSS stroke width into canonical PDF-point space. */
|
||||
export function screenStrokeWidthToPdf(width: number, vp: ViewportParams): number {
|
||||
return width / vp.scale;
|
||||
}
|
||||
|
||||
/** Convert a canonical PDF-point stroke width into Fabric/CSS pixels. */
|
||||
export function pdfStrokeWidthToScreen(width: number, vp: ViewportParams): number {
|
||||
return width * vp.scale;
|
||||
}
|
||||
|
||||
/** Preserve legacy records whose stroke width was stored in screen pixels. */
|
||||
export function storedStrokeWidthToScreen(
|
||||
width: number,
|
||||
unit: StrokeWidthUnit | undefined,
|
||||
vp: ViewportParams,
|
||||
): number {
|
||||
return unit === 'pdf' ? pdfStrokeWidthToScreen(width, vp) : width;
|
||||
}
|
||||
|
||||
export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
|
||||
// Screen points are CSS pixels. Device-pixel-ratio only controls the PDF.js
|
||||
// backing canvas; including it here would make Fabric objects overflow the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue