Rescue Paperjet v1 implementation
This commit is contained in:
parent
7ff5c8130d
commit
db9e2ca51b
83 changed files with 6186 additions and 3894 deletions
|
|
@ -1,73 +1,20 @@
|
|||
# React + TypeScript + Vite
|
||||
# PaperJet frontend
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
This directory contains the React/Vite client for PaperJet. Run the full stack
|
||||
from the repository root; the root [README](../README.md) has Docker and local
|
||||
development instructions.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## Useful commands
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```sh
|
||||
npm ci
|
||||
npm run dev # Vite with /api proxied to localhost:8000
|
||||
npm test -- --run
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
The editor renders PDF pages with PDF.js and uses Fabric.js for the annotation
|
||||
layer. Annotation state is persisted through the backend API; the browser does
|
||||
not write PDF bytes directly.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const LoginForm = () => {
|
|||
try {
|
||||
await login({ password })
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Error is handled by the store
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export const SetupForm = () => {
|
|||
try {
|
||||
await setup({ password })
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Error is handled by the store
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ interface AuthState {
|
|||
clearError: () => void
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown, fallback: string) {
|
||||
if (reason instanceof Error && reason.message) return reason.message;
|
||||
if (typeof reason === 'object' && reason !== null && 'error' in reason) {
|
||||
const error = reason.error;
|
||||
if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>((set) => ({
|
||||
isInitialized: false,
|
||||
setupRequired: false,
|
||||
|
|
@ -33,9 +44,9 @@ export const useAuth = create<AuthState>((set) => ({
|
|||
isInitialized: true,
|
||||
isLoading: false
|
||||
})
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
set({
|
||||
error: err.error?.message || err.message || 'Failed to initialize',
|
||||
error: errorMessage(err, 'Failed to initialize'),
|
||||
isLoading: false,
|
||||
isInitialized: true
|
||||
})
|
||||
|
|
@ -47,9 +58,9 @@ export const useAuth = create<AuthState>((set) => ({
|
|||
set({ isLoading: true, error: null })
|
||||
await authApi.login(data)
|
||||
set({ loggedIn: true, isLoading: false })
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
set({
|
||||
error: err.error?.message || err.message || 'Failed to login',
|
||||
error: errorMessage(err, 'Failed to login'),
|
||||
isLoading: false
|
||||
})
|
||||
throw err
|
||||
|
|
@ -61,9 +72,9 @@ export const useAuth = create<AuthState>((set) => ({
|
|||
set({ isLoading: true, error: null })
|
||||
await authApi.setup(data)
|
||||
set({ setupRequired: false, loggedIn: true, isLoading: false })
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
set({
|
||||
error: err.error?.message || err.message || 'Failed to setup',
|
||||
error: errorMessage(err, 'Failed to setup'),
|
||||
isLoading: false
|
||||
})
|
||||
throw err
|
||||
|
|
@ -76,9 +87,9 @@ export const useAuth = create<AuthState>((set) => ({
|
|||
await authApi.logout()
|
||||
set({ loggedIn: false, isLoading: false })
|
||||
window.location.href = '/login'
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
set({
|
||||
error: err.error?.message || err.message || 'Failed to logout',
|
||||
error: errorMessage(err, 'Failed to logout'),
|
||||
isLoading: false
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as fabric from 'fabric';
|
||||
import { useEditorStore } from '../store';
|
||||
import { getTool } from '../../../lib/annotations/registry';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
import { screenToPdf } from '../../../lib/coords';
|
||||
import { screenRectToPdf } from '../../../lib/coords';
|
||||
import { TextFormatToolbar } from '../toolbar/TextFormatToolbar';
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
|
|
@ -13,208 +13,201 @@ interface AnnotationLayerProps {
|
|||
viewportParams: ViewportParams;
|
||||
}
|
||||
|
||||
type AnnotationObject = fabric.FabricObject & { id?: string };
|
||||
|
||||
export function AnnotationLayer({ pageNumber, width, height, viewportParams }: AnnotationLayerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fabricRef = useRef<fabric.Canvas | null>(null);
|
||||
|
||||
const activeToolName = useEditorStore(state => state.activeTool);
|
||||
|
||||
const annotations = useEditorStore(state => state.annotations);
|
||||
|
||||
const renderingIds = useRef(new Set<string>());
|
||||
const renderGeneration = useRef(0);
|
||||
const latestViewportParams = useRef(viewportParams);
|
||||
const previousDimensions = useRef({ width, height });
|
||||
const [canvasInstance, setCanvasInstance] = useState<fabric.Canvas | null>(null);
|
||||
|
||||
const activeToolName = useEditorStore((state) => state.activeTool);
|
||||
const annotations = useEditorStore((state) => state.annotations);
|
||||
const selection = useEditorStore((state) => state.selection);
|
||||
const draftAnnotation = useEditorStore((state) => state.draftAnnotation);
|
||||
|
||||
useEffect(() => {
|
||||
latestViewportParams.current = viewportParams;
|
||||
}, [viewportParams]);
|
||||
|
||||
// Initial setup and event binding
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const pendingRenderIds = renderingIds.current;
|
||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||
width,
|
||||
height,
|
||||
selection: activeToolName === 'select',
|
||||
enableRetinaScaling: true,
|
||||
});
|
||||
|
||||
(window as any).__fabricCanvas = canvas;
|
||||
|
||||
// Sync fabric modifications back to Zustand
|
||||
canvas.on('object:modified', (e) => {
|
||||
const obj = e.target as any;
|
||||
if (obj && obj.id) {
|
||||
// Find existing annotation
|
||||
const ann = useEditorStore.getState().annotations.find(a => a.id === obj.id);
|
||||
if (ann) {
|
||||
const currentVp = latestViewportParams.current;
|
||||
const pt = screenToPdf({ x: obj.left, y: obj.top }, currentVp);
|
||||
|
||||
const scaleX = obj.scaleX || 1;
|
||||
const scaleY = obj.scaleY || 1;
|
||||
|
||||
// Box width/height in PDF space
|
||||
const newWidth = (obj.width * scaleX) / (currentVp.scale * (currentVp.dpr || 1));
|
||||
const newHeight = (obj.height * scaleY) / (currentVp.scale * (currentVp.dpr || 1));
|
||||
|
||||
useEditorStore.getState().updateAnnotation(obj.id, {
|
||||
rect: { ...ann.rect, x: pt.x, y: pt.y, width: newWidth, height: newHeight }
|
||||
});
|
||||
}
|
||||
}
|
||||
fabricRef.current = canvas;
|
||||
setCanvasInstance(canvas);
|
||||
|
||||
canvas.on('object:modified', (event) => {
|
||||
const object = event.target as AnnotationObject | undefined;
|
||||
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, {
|
||||
rect: screenRectToPdf(
|
||||
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
|
||||
latestViewportParams.current,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const updateSelection = (_e?: any) => {
|
||||
const activeObj = canvas.getActiveObject() as any;
|
||||
if (activeObj && activeObj.id) {
|
||||
useEditorStore.getState().setSelection(activeObj.id);
|
||||
} else {
|
||||
useEditorStore.getState().setSelection(null);
|
||||
}
|
||||
const updateSelection = () => {
|
||||
const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
|
||||
useEditorStore.getState().setSelection(activeObject?.id ?? null);
|
||||
};
|
||||
|
||||
canvas.on('selection:created', updateSelection);
|
||||
canvas.on('selection:updated', updateSelection);
|
||||
canvas.on('selection:cleared', updateSelection);
|
||||
|
||||
// Keyboard shortcut for delete
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't intercept if user is typing in an input field outside of canvas
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
const activeObj = canvas.getActiveObject() as any;
|
||||
if (activeObj && activeObj.isEditing === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeObjects = canvas.getActiveObjects();
|
||||
if (activeObjects && activeObjects.length > 0) {
|
||||
activeObjects.forEach((obj: any) => {
|
||||
if (obj.id) {
|
||||
useEditorStore.getState().deleteAnnotation(obj.id);
|
||||
}
|
||||
});
|
||||
canvas.discardActiveObject();
|
||||
canvas.requestRenderAll();
|
||||
e.preventDefault();
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
|
||||
if (event.key !== 'Delete' && event.key !== 'Backspace') return;
|
||||
const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
|
||||
if (activeObject && 'isEditing' in activeObject && activeObject.isEditing) return;
|
||||
const activeObjects = canvas.getActiveObjects() as AnnotationObject[];
|
||||
if (!activeObjects.length) return;
|
||||
for (const object of activeObjects) {
|
||||
if (object.id) useEditorStore.getState().deleteAnnotation(object.id);
|
||||
}
|
||||
canvas.discardActiveObject();
|
||||
canvas.requestRenderAll();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
fabricRef.current = canvas;
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
pendingRenderIds.clear();
|
||||
renderGeneration.current += 1;
|
||||
canvas.dispose();
|
||||
fabricRef.current = null;
|
||||
setCanvasInstance(null);
|
||||
};
|
||||
}, []); // Run once on mount
|
||||
// The canvas is created once for this page. Dimension and tool changes are
|
||||
// handled by the effects below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const prevScaleRef = useRef(viewportParams.scale);
|
||||
|
||||
// Handle dimensions and re-rendering annotations
|
||||
useEffect(() => {
|
||||
if (!fabricRef.current) return;
|
||||
const canvas = fabricRef.current;
|
||||
canvas.setDimensions({ width, height });
|
||||
|
||||
const activeObj = canvas.getActiveObject() as any;
|
||||
const activeId = activeObj?.id;
|
||||
|
||||
const pageAnns = annotations.filter(a => a.page === pageNumber);
|
||||
const annIds = new Set(pageAnns.map(a => a.id));
|
||||
|
||||
const scaleChanged = prevScaleRef.current !== viewportParams.scale;
|
||||
prevScaleRef.current = viewportParams.scale;
|
||||
|
||||
// Remove objects that are no longer in annotations, OR if the zoom scale changed
|
||||
canvas.getObjects().forEach((obj: any) => {
|
||||
// Don't remove the active object unless it was deleted from the store,
|
||||
// UNLESS the scale changed (we must recreate everything on zoom)
|
||||
if (!scaleChanged && obj.id === activeId && annIds.has(activeId)) return;
|
||||
|
||||
if (scaleChanged || !annIds.has(obj.id)) {
|
||||
canvas.remove(obj);
|
||||
}
|
||||
});
|
||||
|
||||
// Add missing annotations
|
||||
const canvasObjIds = new Set(canvas.getObjects().map((o: any) => o.id));
|
||||
pageAnns.forEach(ann => {
|
||||
if (!scaleChanged && ann.id === activeId) return; // Skip rendering the active object
|
||||
|
||||
if (!canvasObjIds.has(ann.id)) {
|
||||
const tool = getTool(ann.type);
|
||||
if (tool && tool.renderToFabric) {
|
||||
tool.renderToFabric(ann, canvas, viewportParams);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (scaleChanged && activeId && annIds.has(activeId)) {
|
||||
const newlyRenderedActiveObj = canvas.getObjects().find((o: any) => o.id === activeId);
|
||||
if (newlyRenderedActiveObj) {
|
||||
canvas.setActiveObject(newlyRenderedActiveObj);
|
||||
}
|
||||
}
|
||||
}, [width, height, annotations, pageNumber, viewportParams.scale]);
|
||||
|
||||
// Handle tool activation/deactivation and event binding
|
||||
useEffect(() => {
|
||||
const canvas = fabricRef.current;
|
||||
if (!canvas) return;
|
||||
const generation = ++renderGeneration.current;
|
||||
renderingIds.current.clear();
|
||||
canvas.setDimensions({ width, height });
|
||||
|
||||
// First deactivate any previous tool logic
|
||||
// This is a bit tricky if we don't remember the previous tool,
|
||||
// but we can just clear event listeners.
|
||||
canvas.off('mouse:down');
|
||||
canvas.off('mouse:move');
|
||||
canvas.off('mouse:up');
|
||||
|
||||
canvas.selection = activeToolName === 'select';
|
||||
const pageAnnotations = annotations.filter((annotation) => annotation.page === pageNumber);
|
||||
const annotationIds = new Set(pageAnnotations.map((annotation) => annotation.id));
|
||||
const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
|
||||
const activeId = activeObject?.id;
|
||||
const scaleChanged = previousDimensions.current.width !== width || previousDimensions.current.height !== height;
|
||||
previousDimensions.current = { width, height };
|
||||
|
||||
const tool = getTool(activeToolName);
|
||||
if (tool) {
|
||||
if (tool.onActivate) tool.onActivate(canvas);
|
||||
|
||||
if (tool.onPointerDown) {
|
||||
canvas.on('mouse:down', (e) => tool.onPointerDown!(e, canvas, latestViewportParams.current, pageNumber));
|
||||
}
|
||||
if (tool.onPointerMove) {
|
||||
canvas.on('mouse:move', (e) => tool.onPointerMove!(e, canvas, latestViewportParams.current, pageNumber));
|
||||
}
|
||||
if (tool.onPointerUp) {
|
||||
canvas.on('mouse:up', (e) => tool.onPointerUp!(e, canvas, latestViewportParams.current, pageNumber));
|
||||
}
|
||||
if (tool.onPathCreated) {
|
||||
canvas.on('path:created', (e) => tool.onPathCreated!(e, canvas, latestViewportParams.current, pageNumber));
|
||||
for (const object of canvas.getObjects() as AnnotationObject[]) {
|
||||
if (!object.id || !annotationIds.has(object.id) || scaleChanged) {
|
||||
canvas.remove(object);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (tool && tool.onDeactivate) {
|
||||
tool.onDeactivate(canvas);
|
||||
}
|
||||
};
|
||||
}, [activeToolName]);
|
||||
for (const annotation of pageAnnotations) {
|
||||
if (annotation.id === activeId && !scaleChanged) continue;
|
||||
if (canvas.getObjects().some((object) => (object as AnnotationObject).id === annotation.id)) continue;
|
||||
if (renderingIds.current.has(annotation.id)) continue;
|
||||
const tool = getTool(annotation.type);
|
||||
if (!tool?.renderToFabric) continue;
|
||||
renderingIds.current.add(annotation.id);
|
||||
const objectsBeforeRender = new Set(canvas.getObjects());
|
||||
void Promise.resolve()
|
||||
.then(() => tool.renderToFabric?.(annotation, canvas, viewportParams))
|
||||
.then(() => {
|
||||
const addedObjects = canvas.getObjects().filter(
|
||||
(object) => !objectsBeforeRender.has(object) && (object as AnnotationObject).id === annotation.id,
|
||||
);
|
||||
if (generation !== renderGeneration.current) {
|
||||
for (const object of addedObjects) canvas.remove(object);
|
||||
return;
|
||||
}
|
||||
if (activeId) {
|
||||
const renderedActive = canvas.getObjects().find(
|
||||
(object) => (object as AnnotationObject).id === activeId,
|
||||
);
|
||||
if (renderedActive) canvas.setActiveObject(renderedActive);
|
||||
}
|
||||
canvas.requestRenderAll();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(`Failed to render ${annotation.type} annotation`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
renderingIds.current.delete(annotation.id);
|
||||
});
|
||||
}
|
||||
|
||||
const selection = useEditorStore(state => state.selection);
|
||||
const draftAnnotation = useEditorStore(state => state.draftAnnotation);
|
||||
const isSelectedOnThisPage = annotations.find(a => a.id === selection && a.page === pageNumber);
|
||||
const isDraftOnThisPage = draftAnnotation?.page === pageNumber;
|
||||
const activeAnn = isDraftOnThisPage ? draftAnnotation : isSelectedOnThisPage;
|
||||
const toolbarAnnotationId = activeAnn ? activeAnn.id : null;
|
||||
if (activeId) {
|
||||
const renderedActive = canvas.getObjects().find(
|
||||
(object) => (object as AnnotationObject).id === activeId,
|
||||
);
|
||||
if (renderedActive) canvas.setActiveObject(renderedActive);
|
||||
}
|
||||
canvas.requestRenderAll();
|
||||
}, [annotations, height, pageNumber, viewportParams, width]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = fabricRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.off('mouse:down');
|
||||
canvas.off('mouse:move');
|
||||
canvas.off('mouse:up');
|
||||
canvas.off('path:created');
|
||||
canvas.selection = activeToolName === 'select';
|
||||
|
||||
const tool = getTool(activeToolName);
|
||||
if (!tool) return;
|
||||
tool.onActivate?.(canvas);
|
||||
if (tool.onPointerDown) {
|
||||
canvas.on('mouse:down', (event) =>
|
||||
tool.onPointerDown?.(event, canvas, latestViewportParams.current, pageNumber),
|
||||
);
|
||||
}
|
||||
if (tool.onPointerMove) {
|
||||
canvas.on('mouse:move', (event) =>
|
||||
tool.onPointerMove?.(event, canvas, latestViewportParams.current, pageNumber),
|
||||
);
|
||||
}
|
||||
if (tool.onPointerUp) {
|
||||
canvas.on('mouse:up', (event) =>
|
||||
tool.onPointerUp?.(event, canvas, latestViewportParams.current, pageNumber),
|
||||
);
|
||||
}
|
||||
if (tool.onPathCreated) {
|
||||
canvas.on('path:created', (event) =>
|
||||
tool.onPathCreated?.(event, canvas, latestViewportParams.current, pageNumber),
|
||||
);
|
||||
}
|
||||
return () => tool.onDeactivate?.(canvas);
|
||||
}, [activeToolName, pageNumber]);
|
||||
|
||||
const activeAnnotation =
|
||||
draftAnnotation?.page === pageNumber
|
||||
? draftAnnotation
|
||||
: annotations.find((annotation) => annotation.id === selection && annotation.page === pageNumber);
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 left-0" style={{ width, height }}>
|
||||
<div className="absolute left-0 top-0" style={{ width, height }}>
|
||||
<canvas ref={canvasRef} />
|
||||
{toolbarAnnotationId && activeAnn && (
|
||||
<>
|
||||
{activeAnn.type === 'text' && <TextFormatToolbar annotationId={toolbarAnnotationId} viewportParams={viewportParams} />}
|
||||
{/* We will add ShapeControls and DrawControls here once they are implemented */}
|
||||
</>
|
||||
{activeAnnotation?.type === 'text' && canvasInstance && (
|
||||
<TextFormatToolbar
|
||||
annotationId={activeAnnotation.id}
|
||||
viewportParams={viewportParams}
|
||||
canvas={canvasInstance}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,87 +1,127 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
|
||||
import { AnnotationLayer } from '../canvas/AnnotationLayer';
|
||||
|
||||
interface PageRendererProps {
|
||||
pdfDoc: PDFDocumentProxy;
|
||||
pageNumber: number;
|
||||
pageIndex: number;
|
||||
scale: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export function PageRenderer({ pdfDoc, pageNumber, scale, dpr }: PageRendererProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
export function PageRenderer({ pdfDoc, pageIndex, scale, dpr }: PageRendererProps) {
|
||||
const [page, setPage] = useState<PDFPageProxy | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
pdfDoc.getPage(pageNumber).then(p => {
|
||||
if (active) setPage(p);
|
||||
const pageNumber = pageIndex + 1;
|
||||
pdfDoc.getPage(pageNumber).then((nextPage) => {
|
||||
if (active) setPage(nextPage);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [pdfDoc, pageNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!page || !canvasRef.current) return;
|
||||
|
||||
const viewport = page.getViewport({ scale: scale * dpr });
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
canvas.style.width = `${viewport.width / dpr}px`;
|
||||
canvas.style.height = `${viewport.height / dpr}px`;
|
||||
|
||||
const renderContext = {
|
||||
canvas: canvas,
|
||||
viewport: viewport,
|
||||
};
|
||||
|
||||
let renderTask = page.render(renderContext);
|
||||
|
||||
return () => {
|
||||
renderTask.cancel();
|
||||
active = false;
|
||||
};
|
||||
}, [page, scale, dpr]);
|
||||
}, [pdfDoc, pageIndex]);
|
||||
|
||||
if (!page) {
|
||||
return (
|
||||
<div
|
||||
className="bg-white shadow-sm flex items-center justify-center text-neutral-400 border border-neutral-200"
|
||||
<div
|
||||
className="flex items-center justify-center border border-neutral-200 bg-white text-neutral-400 shadow-sm"
|
||||
style={{ width: 612 * scale, height: 792 * scale }}
|
||||
>
|
||||
Loading page {pageNumber}...
|
||||
Loading page {pageIndex + 1}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const baseViewport = page ? page.getViewport({ scale: 1, rotation: 0 }) : null;
|
||||
const canonicalViewport = page.getViewport({ scale: 1, rotation: 0 });
|
||||
const renderedViewport = page.getViewport({
|
||||
scale: scale * dpr,
|
||||
rotation: page.rotate,
|
||||
});
|
||||
const cssWidth = renderedViewport.width / dpr;
|
||||
const cssHeight = renderedViewport.height / dpr;
|
||||
|
||||
return (
|
||||
<div className="relative shadow-xl bg-white border border-neutral-200 group">
|
||||
<canvas ref={canvasRef} className="block" />
|
||||
|
||||
{baseViewport && (
|
||||
<AnnotationLayer
|
||||
pageNumber={pageNumber}
|
||||
width={baseViewport.width * scale}
|
||||
height={baseViewport.height * scale}
|
||||
viewportParams={{
|
||||
scale,
|
||||
rotation: page.rotate,
|
||||
canonicalWidth: baseViewport.width,
|
||||
canonicalHeight: baseViewport.height,
|
||||
dpr
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute -left-10 top-2 text-xs font-bold text-neutral-400 bg-white/80 rounded-md px-1.5 py-0.5 shadow-sm border border-neutral-200">
|
||||
{pageNumber}
|
||||
<RenderedPage
|
||||
page={page}
|
||||
pageIndex={pageIndex}
|
||||
scale={scale}
|
||||
dpr={dpr}
|
||||
canonicalWidth={canonicalViewport.width}
|
||||
canonicalHeight={canonicalViewport.height}
|
||||
renderedWidth={renderedViewport.width}
|
||||
renderedHeight={renderedViewport.height}
|
||||
cssWidth={cssWidth}
|
||||
cssHeight={cssHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderedPageProps {
|
||||
page: PDFPageProxy;
|
||||
pageIndex: number;
|
||||
scale: number;
|
||||
dpr: number;
|
||||
canonicalWidth: number;
|
||||
canonicalHeight: number;
|
||||
renderedWidth: number;
|
||||
renderedHeight: number;
|
||||
cssWidth: number;
|
||||
cssHeight: number;
|
||||
}
|
||||
|
||||
function RenderedPage({
|
||||
page,
|
||||
pageIndex,
|
||||
scale,
|
||||
dpr,
|
||||
canonicalWidth,
|
||||
canonicalHeight,
|
||||
renderedWidth,
|
||||
renderedHeight,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
}: RenderedPageProps) {
|
||||
const viewportParams = useMemo(() => ({
|
||||
scale,
|
||||
rotation: page.rotate,
|
||||
canonicalWidth,
|
||||
canonicalHeight,
|
||||
dpr,
|
||||
}), [canonicalHeight, canonicalWidth, dpr, page.rotate, scale]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>(
|
||||
`[data-paperjet-page="${pageIndex}"]`,
|
||||
);
|
||||
if (!canvas) return;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
canvas.width = renderedWidth;
|
||||
canvas.height = renderedHeight;
|
||||
canvas.style.width = `${cssWidth}px`;
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
|
||||
const renderTask = page.render({ canvas, viewport: page.getViewport({ scale: scale * dpr, rotation: page.rotate }) });
|
||||
renderTask.promise.catch(() => undefined);
|
||||
return () => {
|
||||
renderTask.cancel();
|
||||
};
|
||||
}, [page, pageIndex, scale, dpr, renderedWidth, renderedHeight, cssWidth, cssHeight]);
|
||||
|
||||
return (
|
||||
<div className="group relative border border-neutral-200 bg-white shadow-xl" style={{ width: cssWidth, height: cssHeight }}>
|
||||
<canvas data-paperjet-page={pageIndex} className="block" />
|
||||
<AnnotationLayer
|
||||
pageNumber={pageIndex}
|
||||
width={cssWidth}
|
||||
height={cssHeight}
|
||||
viewportParams={viewportParams}
|
||||
/>
|
||||
<div className="absolute -left-10 top-2 rounded-md border border-neutral-200 bg-white/80 px-1.5 py-0.5 text-xs font-bold text-neutral-400 shadow-sm">
|
||||
{pageIndex + 1}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ interface PageStackProps {
|
|||
|
||||
export function PageStack({ pdfDoc }: PageStackProps) {
|
||||
const numPages = pdfDoc.numPages;
|
||||
const pages = Array.from({ length: numPages }, (_, i) => i + 1);
|
||||
const pages = Array.from({ length: numPages }, (_, i) => i);
|
||||
|
||||
const zoom = useEditorStore(state => state.zoom);
|
||||
|
||||
// For now, render all pages vertically. Virtualization comes in Phase 7.
|
||||
const scale = zoom * 2.0;
|
||||
// PDF points map directly to CSS pixels at 100%. Device pixel ratio is
|
||||
// applied only to the PDF.js backing canvas, not the annotation overlay.
|
||||
const scale = zoom;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
return (
|
||||
|
|
@ -22,7 +23,7 @@ export function PageStack({ pdfDoc }: PageStackProps) {
|
|||
<PageRenderer
|
||||
key={pageNum}
|
||||
pdfDoc={pdfDoc}
|
||||
pageNumber={pageNum}
|
||||
pageIndex={pageNum}
|
||||
scale={scale}
|
||||
dpr={dpr}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,50 +4,61 @@ import { PageStack } from './PageStack';
|
|||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.mjs',
|
||||
import.meta.url
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
interface LoadedDocument {
|
||||
id: string;
|
||||
document: pdfjsLib.PDFDocumentProxy;
|
||||
}
|
||||
|
||||
interface DocumentError {
|
||||
id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function PdfDocument({ documentId }: { documentId: string }) {
|
||||
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [loaded, setLoaded] = useState<LoadedDocument | null>(null);
|
||||
const [error, setError] = useState<DocumentError | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const url = `/api/v1/documents/${documentId}/file`;
|
||||
|
||||
const loadingTask = pdfjsLib.getDocument({ url });
|
||||
|
||||
loadingTask.promise.then((doc) => {
|
||||
if (active) setPdfDoc(doc);
|
||||
}).catch(err => {
|
||||
console.error('Failed to load PDF', err);
|
||||
if (active) setError(err.message);
|
||||
const loadingTask = pdfjsLib.getDocument({ url: `/api/v1/documents/${documentId}/file` });
|
||||
|
||||
loadingTask.promise.then((document) => {
|
||||
if (active) setLoaded({ id: documentId, document });
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) {
|
||||
const message = reason instanceof Error ? reason.message : 'Unable to load this PDF.';
|
||||
setError({ id: documentId, message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
loadingTask.destroy();
|
||||
void loadingTask.destroy();
|
||||
};
|
||||
}, [documentId]);
|
||||
|
||||
if (error) {
|
||||
|
||||
const currentError = error?.id === documentId ? error.message : null;
|
||||
const currentDocument = loaded?.id === documentId ? loaded.document : null;
|
||||
|
||||
if (currentError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<div className="text-red-500 bg-red-50 px-4 py-3 rounded-lg border border-red-200">
|
||||
Error loading PDF: {error}
|
||||
</div>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-600">Error loading PDF: {currentError}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pdfDoc) {
|
||||
|
||||
if (!currentDocument) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full w-full gap-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500"></div>
|
||||
<div className="text-neutral-500 font-medium animate-pulse">Loading document...</div>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-accent-500" />
|
||||
<div className="font-medium animate-pulse text-neutral-500">Loading document…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PageStack pdfDoc={pdfDoc} />;
|
||||
|
||||
return <PageStack pdfDoc={currentDocument} />;
|
||||
}
|
||||
|
|
|
|||
56
frontend/src/features/editor/store.test.ts
Normal file
56
frontend/src/features/editor/store.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useEditorStore } from './store';
|
||||
import type { TextAnnotation } from '../../lib/annotations/types';
|
||||
|
||||
const annotation: TextAnnotation = {
|
||||
id: 'note-1',
|
||||
page: 0,
|
||||
type: 'text',
|
||||
rect: { x: 10, y: 20, width: 100, height: 30 },
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
props: {
|
||||
text: 'Original',
|
||||
fontFamily: 'Liberation Sans',
|
||||
fontSize: 14,
|
||||
color: '#000000',
|
||||
align: 'left',
|
||||
bold: false,
|
||||
italic: false,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
createdAt: '2026-08-14T00:00:00Z',
|
||||
updatedAt: '2026-08-14T00:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useEditorStore.getState().setDocumentId(null);
|
||||
});
|
||||
|
||||
describe('editor history', () => {
|
||||
it('undoes and redoes annotation mutations without sharing mutable state', () => {
|
||||
const store = useEditorStore.getState();
|
||||
store.setDocumentId('document-1');
|
||||
store.addAnnotation(annotation);
|
||||
store.updateAnnotation('note-1', { props: { ...annotation.props, text: 'Updated' } });
|
||||
|
||||
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Updated' });
|
||||
useEditorStore.getState().undo();
|
||||
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Original' });
|
||||
useEditorStore.getState().redo();
|
||||
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Updated' });
|
||||
});
|
||||
|
||||
it('clears working state and history when switching documents', () => {
|
||||
const store = useEditorStore.getState();
|
||||
store.setDocumentId('document-1');
|
||||
store.addAnnotation(annotation);
|
||||
store.setDocumentId('document-2');
|
||||
|
||||
const next = useEditorStore.getState();
|
||||
expect(next.documentId).toBe('document-2');
|
||||
expect(next.annotations).toEqual([]);
|
||||
expect(next.past).toEqual([]);
|
||||
expect(next.future).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,74 +1,171 @@
|
|||
import { create } from 'zustand';
|
||||
import type { Annotation, TextProps } from '../../lib/annotations/types';
|
||||
import type {
|
||||
Annotation,
|
||||
SignatureDrawProps,
|
||||
SignatureTypeProps,
|
||||
TextProps,
|
||||
} from '../../lib/annotations/types';
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||
export type SaveStatus = 'idle' | 'loading' | 'saving' | 'saved' | 'error';
|
||||
type SignatureProps = SignatureDrawProps | SignatureTypeProps;
|
||||
type PendingImage = { ref: string; width: number; height: number };
|
||||
|
||||
interface EditorState {
|
||||
documentId: string | null;
|
||||
annotations: Annotation[];
|
||||
activeTool: string;
|
||||
past: Annotation[][];
|
||||
future: Annotation[][];
|
||||
selection: string | null;
|
||||
activeTool: string;
|
||||
activeShapeKind: 'rect' | 'ellipse' | 'line' | 'arrow';
|
||||
zoom: number;
|
||||
saveStatus: SaveStatus;
|
||||
annotationUpdatedAt: string | null;
|
||||
defaultTextProps: Partial<TextProps>;
|
||||
draftAnnotation: Annotation | null;
|
||||
isSignatureModalOpen: boolean;
|
||||
pendingSignatureProps: any | null;
|
||||
pendingImageRef: { ref: string, width: number, height: number } | null;
|
||||
|
||||
// Actions
|
||||
pendingSignatureProps: SignatureProps | null;
|
||||
pendingImageRef: PendingImage | null;
|
||||
|
||||
setDocumentId: (id: string | null) => void;
|
||||
setAnnotations: (annotations: Annotation[]) => void;
|
||||
setAnnotationUpdatedAt: (updatedAt: string | null) => void;
|
||||
addAnnotation: (annotation: Annotation) => void;
|
||||
updateAnnotation: (id: string, updates: Partial<Annotation>) => void;
|
||||
deleteAnnotation: (id: string) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
setActiveTool: (tool: string) => void;
|
||||
setSelection: (id: string | null) => void;
|
||||
setZoom: (zoom: number) => void;
|
||||
setSaveStatus: (status: SaveStatus) => void;
|
||||
setDefaultTextProps: (props: Partial<TextProps>) => void;
|
||||
setDraftAnnotation: (ann: Annotation | null) => void;
|
||||
setDraftAnnotation: (annotation: Annotation | null) => void;
|
||||
setIsSignatureModalOpen: (isOpen: boolean) => void;
|
||||
setPendingSignatureProps: (props: any | null) => void;
|
||||
setPendingImageRef: (imgRef: { ref: string, width: number, height: number } | null) => void;
|
||||
activeShapeKind: 'rect' | 'ellipse' | 'line';
|
||||
setActiveShapeKind: (kind: 'rect' | 'ellipse' | 'line') => void;
|
||||
setPendingSignatureProps: (props: SignatureProps | null) => void;
|
||||
setPendingImageRef: (image: PendingImage | null) => void;
|
||||
setActiveShapeKind: (kind: EditorState['activeShapeKind']) => void;
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set) => ({
|
||||
const MAX_HISTORY = 100;
|
||||
|
||||
function cloneAnnotations(annotations: Annotation[]): Annotation[] {
|
||||
return annotations.map((annotation) => ({
|
||||
...annotation,
|
||||
rect: { ...annotation.rect },
|
||||
props: structuredClone(annotation.props),
|
||||
})) as Annotation[];
|
||||
}
|
||||
|
||||
function withHistory(state: EditorState, annotations: Annotation[]): Partial<EditorState> {
|
||||
return {
|
||||
annotations,
|
||||
past: [...state.past, cloneAnnotations(state.annotations)].slice(-MAX_HISTORY),
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
documentId: null,
|
||||
annotations: [],
|
||||
activeTool: 'select',
|
||||
past: [],
|
||||
future: [],
|
||||
selection: null,
|
||||
activeTool: 'select',
|
||||
activeShapeKind: 'rect',
|
||||
zoom: 1,
|
||||
saveStatus: 'idle',
|
||||
annotationUpdatedAt: null,
|
||||
defaultTextProps: {},
|
||||
draftAnnotation: null,
|
||||
isSignatureModalOpen: false,
|
||||
pendingSignatureProps: null,
|
||||
pendingImageRef: null,
|
||||
activeShapeKind: 'rect',
|
||||
|
||||
setDocumentId: (id) => set({ documentId: id, defaultTextProps: {}, draftAnnotation: null }),
|
||||
setAnnotations: (annotations) => set({ annotations }),
|
||||
addAnnotation: (annotation) => set((state) => ({
|
||||
annotations: [...state.annotations, annotation]
|
||||
})),
|
||||
updateAnnotation: (id, updates) => set((state) => ({
|
||||
annotations: state.annotations.map(a => a.id === id ? { ...a, ...updates } as Annotation : a)
|
||||
})),
|
||||
deleteAnnotation: (id) => set((state) => ({
|
||||
annotations: state.annotations.filter(a => a.id !== id),
|
||||
selection: state.selection === id ? null : state.selection
|
||||
})),
|
||||
setDocumentId: (id) =>
|
||||
set({
|
||||
documentId: id,
|
||||
annotations: [],
|
||||
past: [],
|
||||
future: [],
|
||||
selection: null,
|
||||
saveStatus: id ? 'loading' : 'idle',
|
||||
annotationUpdatedAt: null,
|
||||
defaultTextProps: {},
|
||||
draftAnnotation: null,
|
||||
pendingSignatureProps: null,
|
||||
pendingImageRef: null,
|
||||
}),
|
||||
setAnnotations: (annotations) =>
|
||||
set({
|
||||
annotations: cloneAnnotations(annotations),
|
||||
past: [],
|
||||
future: [],
|
||||
selection: null,
|
||||
draftAnnotation: null,
|
||||
}),
|
||||
setAnnotationUpdatedAt: (updatedAt) => set({ annotationUpdatedAt: updatedAt }),
|
||||
addAnnotation: (annotation) =>
|
||||
set((state) => withHistory(state, cloneAnnotations([...state.annotations, annotation]))),
|
||||
updateAnnotation: (id, updates) =>
|
||||
set((state) => {
|
||||
const current = state.annotations.find((annotation) => annotation.id === id);
|
||||
if (!current) return state;
|
||||
const next = state.annotations.map((annotation) =>
|
||||
annotation.id === id
|
||||
? ({
|
||||
...annotation,
|
||||
...updates,
|
||||
props: updates.props ? { ...annotation.props, ...updates.props } : annotation.props,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Annotation)
|
||||
: annotation,
|
||||
);
|
||||
return withHistory(state, next);
|
||||
}),
|
||||
deleteAnnotation: (id) =>
|
||||
set((state) => {
|
||||
if (!state.annotations.some((annotation) => annotation.id === id)) return state;
|
||||
return {
|
||||
...withHistory(state, state.annotations.filter((annotation) => annotation.id !== id)),
|
||||
selection: state.selection === id ? null : state.selection,
|
||||
};
|
||||
}),
|
||||
undo: () =>
|
||||
set((state) => {
|
||||
const previous = state.past.at(-1);
|
||||
if (!previous) return state;
|
||||
return {
|
||||
annotations: cloneAnnotations(previous),
|
||||
past: state.past.slice(0, -1),
|
||||
future: [cloneAnnotations(state.annotations), ...state.future].slice(0, MAX_HISTORY),
|
||||
selection: null,
|
||||
};
|
||||
}),
|
||||
redo: () =>
|
||||
set((state) => {
|
||||
const next = state.future[0];
|
||||
if (!next) return state;
|
||||
return {
|
||||
annotations: cloneAnnotations(next),
|
||||
past: [...state.past, cloneAnnotations(state.annotations)].slice(-MAX_HISTORY),
|
||||
future: state.future.slice(1),
|
||||
selection: null,
|
||||
};
|
||||
}),
|
||||
canUndo: () => get().past.length > 0,
|
||||
canRedo: () => get().future.length > 0,
|
||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||
setSelection: (id) => set({ selection: id }),
|
||||
setZoom: (zoom) => set({ zoom }),
|
||||
setZoom: (zoom) => set({ zoom: Math.max(0.5, Math.min(3, zoom)) }),
|
||||
setSaveStatus: (status) => set({ saveStatus: status }),
|
||||
setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
|
||||
setDraftAnnotation: (ann) => set({ draftAnnotation: ann }),
|
||||
setDefaultTextProps: (props) =>
|
||||
set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
|
||||
setDraftAnnotation: (annotation) => set({ draftAnnotation: annotation }),
|
||||
setIsSignatureModalOpen: (isOpen) => set({ isSignatureModalOpen: isOpen }),
|
||||
setPendingSignatureProps: (props) => set({ pendingSignatureProps: props }),
|
||||
setPendingImageRef: (imgRef) => set({ pendingImageRef: imgRef }),
|
||||
setPendingImageRef: (image) => set({ pendingImageRef: image }),
|
||||
setActiveShapeKind: (kind) => set({ activeShapeKind: kind }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,161 +1,162 @@
|
|||
import { useRef, useState } from 'react';
|
||||
import { useEditorStore } from '../store';
|
||||
import { useRef, useState, type ChangeEvent } from 'react';
|
||||
import {
|
||||
Download,
|
||||
History,
|
||||
Highlighter,
|
||||
ImagePlus,
|
||||
Minus,
|
||||
MousePointer2,
|
||||
PenLine,
|
||||
Redo2,
|
||||
Signature,
|
||||
Square,
|
||||
Type,
|
||||
Undo2,
|
||||
ZoomIn,
|
||||
} from 'lucide-react';
|
||||
import { SignatureModal } from '../tools/SignatureModal';
|
||||
import { api } from '../../../lib/api/client';
|
||||
import { useEditorStore } from '../store';
|
||||
import type { SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types';
|
||||
|
||||
export function EditorToolbar() {
|
||||
const {
|
||||
activeTool,
|
||||
setActiveTool,
|
||||
saveStatus,
|
||||
zoom,
|
||||
interface EditorToolbarProps {
|
||||
onExport: () => void;
|
||||
onOpenVersions: () => void;
|
||||
isExporting: boolean;
|
||||
}
|
||||
|
||||
interface AssetUploadResponse {
|
||||
ref: string;
|
||||
}
|
||||
|
||||
const toolButton = 'inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold transition-colors';
|
||||
const inactive = 'text-neutral-600 hover:bg-neutral-100';
|
||||
const active = 'bg-accent-100 text-accent-700';
|
||||
|
||||
export function EditorToolbar({ onExport, onOpenVersions, isExporting }: EditorToolbarProps) {
|
||||
const {
|
||||
activeTool,
|
||||
setActiveTool,
|
||||
activeShapeKind,
|
||||
setActiveShapeKind,
|
||||
saveStatus,
|
||||
zoom,
|
||||
setZoom,
|
||||
isSignatureModalOpen,
|
||||
setIsSignatureModalOpen,
|
||||
setPendingSignatureProps,
|
||||
setPendingImageRef,
|
||||
documentId
|
||||
documentId,
|
||||
past,
|
||||
future,
|
||||
undo,
|
||||
redo,
|
||||
} = useEditorStore();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
|
||||
const handleZoomIn = () => setZoom(Math.min(zoom + 0.25, 3));
|
||||
const handleZoomOut = () => setZoom(Math.max(zoom - 0.25, 0.5));
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !documentId) return;
|
||||
|
||||
setIsUploadingImage(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const data = await api.upload<any>(`/documents/${documentId}/assets`, formData);
|
||||
|
||||
// We don't have natural width/height immediately, we'll let the tool figure it out or pass placeholders
|
||||
// A better way is to read image locally to get dimensions, but we can also just let fabric.Image.fromURL do it.
|
||||
// We'll pass 0 for now and let the tool set scale to max-width.
|
||||
const data = await api.upload<AssetUploadResponse>(`/documents/${documentId}/assets`, formData);
|
||||
setPendingImageRef({ ref: data.ref, width: 0, height: 0 });
|
||||
setActiveTool('image');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to upload image');
|
||||
} catch (reason) {
|
||||
console.error('Failed to upload image', reason);
|
||||
window.alert(reason instanceof Error ? reason.message : 'Failed to upload image.');
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
// Reset input
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignatureConfirm = (props: any) => {
|
||||
const handleSignatureConfirm = (props: SignatureDrawProps | SignatureTypeProps) => {
|
||||
setPendingSignatureProps(props);
|
||||
setIsSignatureModalOpen(false);
|
||||
setActiveTool('signature');
|
||||
};
|
||||
|
||||
const isActive = (name: string) => activeTool === name ? active : inactive;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-neutral-200 px-2 py-2 flex items-center gap-2 z-50">
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTool === 'select'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
onClick={() => setActiveTool('select')}
|
||||
>
|
||||
Select
|
||||
<div className="sticky top-3 z-30 mx-auto flex w-fit max-w-[calc(100%-2rem)] flex-wrap items-center justify-center gap-1 rounded-xl border border-neutral-200 bg-white/95 px-2 py-2 shadow-lg backdrop-blur">
|
||||
<button type="button" className={`${toolButton} ${isActive('select')}`} onClick={() => setActiveTool('select')} title="Select and move annotations">
|
||||
<MousePointer2 className="h-4 w-4" /> Select
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTool === 'text'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
onClick={() => setActiveTool('text')}
|
||||
>
|
||||
Add Text
|
||||
<button type="button" className={`${toolButton} ${isActive('text')}`} onClick={() => setActiveTool('text')} title="Add text">
|
||||
<Type className="h-4 w-4" /> Text
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTool === 'draw'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
onClick={() => setActiveTool('draw')}
|
||||
>
|
||||
Draw
|
||||
<button type="button" className={`${toolButton} ${isActive('draw')}`} onClick={() => setActiveTool('draw')} title="Draw freehand">
|
||||
<PenLine className="h-4 w-4" /> Draw
|
||||
</button>
|
||||
|
||||
|
||||
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTool === 'signature'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
onClick={() => setIsSignatureModalOpen(true)}
|
||||
>
|
||||
Signature
|
||||
<button type="button" className={`${toolButton} ${isActive('highlight')}`} onClick={() => setActiveTool('highlight')} title="Highlight an area">
|
||||
<Highlighter className="h-4 w-4" /> Highlight
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTool === 'image' || isUploadingImage
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-neutral-600 hover:bg-neutral-100'
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploadingImage}
|
||||
>
|
||||
{isUploadingImage ? 'Uploading...' : 'Image'}
|
||||
<button type="button" className={`${toolButton} ${isActive('shape')}`} onClick={() => setActiveTool('shape')} title="Draw a shape">
|
||||
<Square className="h-4 w-4" /> Shape
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept="image/*, image/webp"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
|
||||
<div className="w-px h-6 bg-neutral-200 mx-2" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
|
||||
{activeTool === 'shape' && (
|
||||
<select
|
||||
value={activeShapeKind}
|
||||
onChange={(event) => setActiveShapeKind(event.target.value as typeof activeShapeKind)}
|
||||
className="h-8 rounded-md border border-neutral-200 bg-neutral-50 px-1.5 text-xs font-medium text-neutral-700 outline-none focus:border-accent-500"
|
||||
aria-label="Shape type"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="text-sm font-medium text-neutral-600 w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<option value="rect">Rectangle</option>
|
||||
<option value="ellipse">Ellipse</option>
|
||||
<option value="line">Line</option>
|
||||
<option value="arrow">Arrow</option>
|
||||
</select>
|
||||
)}
|
||||
<button type="button" className={`${toolButton} ${isActive('signature')}`} onClick={() => setIsSignatureModalOpen(true)} title="Add a signature">
|
||||
<Signature className="h-4 w-4" /> Sign
|
||||
</button>
|
||||
<button type="button" className={`${toolButton} ${activeTool === 'image' || isUploadingImage ? active : inactive}`} onClick={() => fileInputRef.current?.click()} disabled={isUploadingImage} title="Add an image">
|
||||
<ImagePlus className="h-4 w-4" /> {isUploadingImage ? 'Uploading…' : 'Image'}
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" className="hidden" onChange={(event) => void handleImageUpload(event)} />
|
||||
|
||||
<div className="w-px h-6 bg-neutral-200 mx-2" />
|
||||
<span className="mx-1 h-6 w-px bg-neutral-200" />
|
||||
<button type="button" className={`${toolButton} ${past.length ? inactive : 'cursor-not-allowed text-neutral-300'}`} onClick={undo} disabled={!past.length} title="Undo (Ctrl/Cmd+Z)">
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" className={`${toolButton} ${future.length ? inactive : 'cursor-not-allowed text-neutral-300'}`} onClick={redo} disabled={!future.length} title="Redo (Ctrl/Cmd+Shift+Z)">
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="text-xs font-medium text-neutral-400 min-w-16 text-center">
|
||||
{saveStatus === 'saving' && 'Saving...'}
|
||||
<span className="mx-1 h-6 w-px bg-neutral-200" />
|
||||
<button type="button" className={`${toolButton} ${inactive}`} onClick={() => setZoom(zoom - 0.25)} disabled={zoom <= 0.5} title="Zoom out">
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-12 text-center text-xs font-semibold text-neutral-600">{Math.round(zoom * 100)}%</span>
|
||||
<button type="button" className={`${toolButton} ${inactive}`} onClick={() => setZoom(zoom + 0.25)} disabled={zoom >= 3} title="Zoom in">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span className="mx-1 h-6 w-px bg-neutral-200" />
|
||||
<button type="button" className={`${toolButton} ${inactive}`} onClick={onOpenVersions} title="Open checkpoints">
|
||||
<History className="h-4 w-4" /> History
|
||||
</button>
|
||||
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md bg-accent-600 px-3 text-xs font-bold text-white transition-colors hover:bg-accent-700 disabled:cursor-wait disabled:opacity-60" onClick={onExport} disabled={isExporting} title="Export flattened PDF">
|
||||
<Download className="h-4 w-4" /> {isExporting ? 'Exporting…' : 'Export'}
|
||||
</button>
|
||||
|
||||
<span className="ml-1 min-w-16 text-center text-[11px] font-semibold text-neutral-500">
|
||||
{saveStatus === 'loading' && 'Loading…'}
|
||||
{saveStatus === 'saving' && 'Saving…'}
|
||||
{saveStatus === 'saved' && 'Saved'}
|
||||
{saveStatus === 'error' && <span className="text-red-500">Error saving</span>}
|
||||
{saveStatus === 'idle' && ''}
|
||||
</div>
|
||||
{saveStatus === 'error' && <span className="text-red-600">Save failed</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isSignatureModalOpen && (
|
||||
<SignatureModal
|
||||
<SignatureModal
|
||||
onClose={() => setIsSignatureModalOpen(false)}
|
||||
onConfirm={handleSignatureConfirm}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import * as fabric from 'fabric';
|
||||
import type { Canvas } from 'fabric';
|
||||
import { useEditorStore } from '../store';
|
||||
import type { TextAnnotation, TextProps } from '../../../lib/annotations/types';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
|
|
@ -17,6 +19,7 @@ import {
|
|||
interface TextFormatToolbarProps {
|
||||
annotationId: string;
|
||||
viewportParams: ViewportParams;
|
||||
canvas: Canvas;
|
||||
}
|
||||
|
||||
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New'];
|
||||
|
|
@ -24,7 +27,7 @@ const SIZES = [6, 7, 8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
|
|||
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
|
||||
const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000'];
|
||||
|
||||
export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatToolbarProps) {
|
||||
export function TextFormatToolbar({ annotationId, viewportParams, canvas }: TextFormatToolbarProps) {
|
||||
const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore();
|
||||
|
||||
const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null);
|
||||
|
|
@ -51,11 +54,9 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
|||
const top = pt.y - 48; // 48px above
|
||||
const left = pt.x;
|
||||
|
||||
const applyStyle = (styleName: string, value: any, globalPropName: keyof TextProps, globalValue?: any) => {
|
||||
const canvas = (window as any).__fabricCanvas as any;
|
||||
|
||||
if (canvas) {
|
||||
const activeObj = canvas.getActiveObject();
|
||||
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';
|
||||
|
||||
|
|
@ -81,7 +82,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
|||
activeObj.styles = {};
|
||||
if (activeObj.hiddenTextarea) {
|
||||
if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`;
|
||||
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = value;
|
||||
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -100,7 +101,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
|||
for (const line in activeObj.styles) {
|
||||
for (const char in activeObj.styles[line]) {
|
||||
if (activeObj.styles[line][char]) {
|
||||
delete activeObj.styles[line][char][styleName];
|
||||
delete (activeObj.styles[line][char] as Record<string, unknown>)[styleName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -110,8 +111,8 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
|||
|
||||
// Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw!
|
||||
activeObj.dirty = true;
|
||||
if ((activeObj as any)._forceClearCache !== undefined) {
|
||||
(activeObj as any)._forceClearCache = true;
|
||||
if ('_forceClearCache' in activeObj) {
|
||||
(activeObj as typeof activeObj & { _forceClearCache?: boolean })._forceClearCache = true;
|
||||
}
|
||||
|
||||
// Remove manual height constraint so the box can grow with the new font size
|
||||
|
|
@ -123,7 +124,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
|||
}
|
||||
|
||||
const finalGlobalValue = globalValue !== undefined ? globalValue : value;
|
||||
const newProps = { [globalPropName]: finalGlobalValue } as any;
|
||||
const newProps = { [globalPropName]: finalGlobalValue } as Partial<TextProps>;
|
||||
setDefaultTextProps(newProps);
|
||||
|
||||
// Always update store so the toolbar displays the new value
|
||||
|
|
@ -154,12 +155,9 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
|||
const handleDelete = () => {
|
||||
if (isDraft) {
|
||||
useEditorStore.getState().setDraftAnnotation(null);
|
||||
const canvas = (window as any).__fabricCanvas as any;
|
||||
if (canvas) {
|
||||
const activeObj = canvas.getActiveObject();
|
||||
if (activeObj && activeObj.id === annotationId) {
|
||||
canvas.remove(activeObj);
|
||||
}
|
||||
const activeObj = canvas.getActiveObject();
|
||||
if (activeObj && (activeObj as fabric.FabricObject & { id?: string }).id === annotationId) {
|
||||
canvas.remove(activeObj);
|
||||
}
|
||||
} else {
|
||||
deleteAnnotation(annotationId);
|
||||
|
|
@ -220,7 +218,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
|||
<button
|
||||
key={s}
|
||||
className={`w-full text-center px-3 py-1 text-xs hover:bg-blue-50 ${props.fontSize === s ? 'bg-blue-50 text-blue-600 font-medium' : ''}`}
|
||||
onClick={() => { applyStyle('fontSize', s * viewportParams.scale * (viewportParams.dpr || 1), 'fontSize', s); setActiveDropdown(null); }}
|
||||
onClick={() => { applyStyle('fontSize', s * viewportParams.scale, 'fontSize', s); setActiveDropdown(null); }}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ import type { Annotation, DrawAnnotation, DrawProps } from '../../../lib/annotat
|
|||
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
|
||||
type FabricAnnotationObject = fabric.FabricObject & {
|
||||
id?: string;
|
||||
annotationType?: string;
|
||||
annotationProps?: DrawProps;
|
||||
};
|
||||
|
||||
export const DrawTool: ToolHandler = {
|
||||
name: 'draw',
|
||||
|
||||
|
|
@ -21,7 +27,7 @@ export const DrawTool: ToolHandler = {
|
|||
canvas.isDrawingMode = false;
|
||||
},
|
||||
|
||||
onPathCreated: (e: any, _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||
onPathCreated: (e: fabric.CanvasEvents['path:created'], _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||
const pathObj = e.path as fabric.Path;
|
||||
|
||||
pathObj.set({
|
||||
|
|
@ -33,27 +39,16 @@ export const DrawTool: ToolHandler = {
|
|||
padding: 5,
|
||||
});
|
||||
|
||||
(pathObj as any).id = uuidv4();
|
||||
(pathObj as any).annotationType = 'draw';
|
||||
const annotatedPath = pathObj as FabricAnnotationObject;
|
||||
annotatedPath.id = uuidv4();
|
||||
annotatedPath.annotationType = 'draw';
|
||||
|
||||
// The path data is stored in `pathObj.path` array of commands, e.g. [['M', x, y], ['Q', cx, cy, x, y]]
|
||||
// We can serialize it via pathObj.toObject().path or complexPathToString(pathObj.path)
|
||||
// fabric 7 has toObject().path or you can just rely on the object's serialization
|
||||
|
||||
// Convert paths to string representation
|
||||
// Keep the SVG path for faithful browser re-rendering and also store a
|
||||
// canonical point list for the server export renderer.
|
||||
const pathStr = Array.isArray(pathObj.path)
|
||||
? pathObj.path.map(cmd => cmd.join(' ')).join(' ')
|
||||
: pathObj.path;
|
||||
|
||||
const props: DrawProps = {
|
||||
paths: [], // we ignore points array for now and use svgPath
|
||||
svgPath: pathStr as string,
|
||||
strokeColor: pathObj.stroke as string,
|
||||
strokeWidth: pathObj.strokeWidth,
|
||||
opacity: pathObj.opacity
|
||||
};
|
||||
(pathObj as any).annotationProps = props;
|
||||
|
||||
const bounds = pathObj.getBoundingRect();
|
||||
const pdfRect = screenRectToPdf({
|
||||
x: bounds.left,
|
||||
|
|
@ -61,9 +56,36 @@ export const DrawTool: ToolHandler = {
|
|||
width: bounds.width,
|
||||
height: bounds.height
|
||||
}, vp);
|
||||
const rawPoints = Array.isArray(pathObj.path)
|
||||
? pathObj.path.flatMap((command) => {
|
||||
const values = command.slice(1).filter((value): value is number => typeof value === 'number');
|
||||
const points: [number, number][] = [];
|
||||
for (let index = 0; index + 1 < values.length; index += 2) {
|
||||
points.push([values[index], values[index + 1]]);
|
||||
}
|
||||
return points;
|
||||
})
|
||||
: [];
|
||||
const minX = rawPoints.length ? Math.min(...rawPoints.map(([x]) => x)) : 0;
|
||||
const maxX = rawPoints.length ? Math.max(...rawPoints.map(([x]) => x)) : 1;
|
||||
const minY = rawPoints.length ? Math.min(...rawPoints.map(([, y]) => y)) : 0;
|
||||
const maxY = rawPoints.length ? Math.max(...rawPoints.map(([, y]) => y)) : 1;
|
||||
const sourceWidth = Math.max(maxX - minX, 1e-6);
|
||||
const sourceHeight = Math.max(maxY - minY, 1e-6);
|
||||
const props: DrawProps = {
|
||||
paths: rawPoints.map(([x, y]) => [
|
||||
((x - minX) / sourceWidth) * pdfRect.width,
|
||||
((y - minY) / sourceHeight) * pdfRect.height,
|
||||
]),
|
||||
svgPath: pathStr as string,
|
||||
strokeColor: pathObj.stroke as string,
|
||||
strokeWidth: pathObj.strokeWidth,
|
||||
opacity: pathObj.opacity,
|
||||
};
|
||||
annotatedPath.annotationProps = props;
|
||||
|
||||
const annotation: DrawAnnotation = {
|
||||
id: (pathObj as any).id,
|
||||
id: annotatedPath.id as string,
|
||||
page: pageNumber,
|
||||
type: 'draw',
|
||||
rect: pdfRect,
|
||||
|
|
@ -81,7 +103,7 @@ export const DrawTool: ToolHandler = {
|
|||
},
|
||||
|
||||
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||
if (annotation.type !== 'draw') return null;
|
||||
if (annotation.type !== 'draw') return;
|
||||
const drawAnn = annotation as DrawAnnotation;
|
||||
const props = drawAnn.props as DrawProps;
|
||||
const screenRect = pdfRectToScreen(drawAnn.rect, vp);
|
||||
|
|
@ -103,6 +125,8 @@ export const DrawTool: ToolHandler = {
|
|||
pathObj.set({
|
||||
left: screenRect.x,
|
||||
top: screenRect.y,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
scaleX: screenRect.width / pathObj.width!,
|
||||
scaleY: screenRect.height / pathObj.height!,
|
||||
});
|
||||
|
|
@ -111,6 +135,8 @@ export const DrawTool: ToolHandler = {
|
|||
pathObj = new fabric.Path('M 0 0', {
|
||||
left: screenRect.x,
|
||||
top: screenRect.y,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
width: screenRect.width,
|
||||
height: screenRect.height,
|
||||
stroke: props.strokeColor || '#000000',
|
||||
|
|
@ -118,17 +144,18 @@ export const DrawTool: ToolHandler = {
|
|||
});
|
||||
}
|
||||
|
||||
const annotatedPath = pathObj as FabricAnnotationObject;
|
||||
annotatedPath.id = drawAnn.id;
|
||||
annotatedPath.annotationType = 'draw';
|
||||
annotatedPath.annotationProps = drawAnn.props;
|
||||
pathObj.set({
|
||||
id: drawAnn.id,
|
||||
annotationType: 'draw',
|
||||
annotationProps: drawAnn.props,
|
||||
transparentCorners: false,
|
||||
cornerColor: '#3b82f6',
|
||||
cornerStrokeColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
cornerSize: 8,
|
||||
padding: 5,
|
||||
} as any);
|
||||
});
|
||||
|
||||
canvas.add(pathObj);
|
||||
}
|
||||
|
|
|
|||
120
frontend/src/features/editor/tools/HighlightTool.ts
Normal file
120
frontend/src/features/editor/tools/HighlightTool.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import * as fabric from 'fabric';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useEditorStore } from '../store';
|
||||
import type { ToolHandler } from '../../../lib/annotations/registry';
|
||||
import type { Annotation, HighlightAnnotation, HighlightProps } from '../../../lib/annotations/types';
|
||||
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
|
||||
type PointerEvent = fabric.TPointerEventInfo;
|
||||
type Draft = { object: fabric.Rect; start: { x: number; y: number } };
|
||||
const drafts = new WeakMap<fabric.Canvas, Draft>();
|
||||
|
||||
function point(event: PointerEvent, canvas: fabric.Canvas) {
|
||||
return event.scenePoint ?? canvas.getScenePoint(event.e);
|
||||
}
|
||||
|
||||
function configure(object: fabric.Rect) {
|
||||
object.set({
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
fill: '#facc15',
|
||||
opacity: 0.35,
|
||||
stroke: undefined,
|
||||
transparentCorners: false,
|
||||
cornerColor: '#3b82f6',
|
||||
cornerStrokeColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
cornerSize: 8,
|
||||
});
|
||||
}
|
||||
|
||||
export const HighlightTool: ToolHandler = {
|
||||
name: 'highlight',
|
||||
onActivate: (canvas) => {
|
||||
canvas.defaultCursor = 'crosshair';
|
||||
canvas.selection = false;
|
||||
},
|
||||
onDeactivate: (canvas) => {
|
||||
const draft = drafts.get(canvas);
|
||||
if (draft) canvas.remove(draft.object);
|
||||
drafts.delete(canvas);
|
||||
canvas.defaultCursor = 'default';
|
||||
},
|
||||
onPointerDown: (event: PointerEvent, canvas) => {
|
||||
if (event.target) return;
|
||||
const start = point(event, canvas);
|
||||
const object = new fabric.Rect({ left: start.x, top: start.y, width: 0, height: 0 });
|
||||
configure(object);
|
||||
drafts.set(canvas, { object, start });
|
||||
canvas.add(object);
|
||||
},
|
||||
onPointerMove: (event: PointerEvent, canvas) => {
|
||||
const draft = drafts.get(canvas);
|
||||
if (!draft) return;
|
||||
const current = point(event, canvas);
|
||||
draft.object.set({
|
||||
left: Math.min(draft.start.x, current.x),
|
||||
top: Math.min(draft.start.y, current.y),
|
||||
width: Math.abs(current.x - draft.start.x),
|
||||
height: Math.abs(current.y - draft.start.y),
|
||||
});
|
||||
draft.object.setCoords();
|
||||
canvas.requestRenderAll();
|
||||
},
|
||||
onPointerUp: (event: PointerEvent, canvas, vp: ViewportParams, pageNumber) => {
|
||||
const draft = drafts.get(canvas);
|
||||
if (!draft) return;
|
||||
const current = point(event, canvas);
|
||||
draft.object.set({
|
||||
left: Math.min(draft.start.x, current.x),
|
||||
top: Math.min(draft.start.y, current.y),
|
||||
width: Math.abs(current.x - draft.start.x),
|
||||
height: Math.abs(current.y - draft.start.y),
|
||||
});
|
||||
draft.object.setCoords();
|
||||
const bounds = draft.object.getBoundingRect();
|
||||
drafts.delete(canvas);
|
||||
if (bounds.width < 2 || bounds.height < 2) {
|
||||
canvas.remove(draft.object);
|
||||
return;
|
||||
}
|
||||
const id = uuidv4();
|
||||
draft.object.set({ id });
|
||||
const props: HighlightProps = { color: '#facc15', opacity: 0.35 };
|
||||
useEditorStore.getState().addAnnotation({
|
||||
id,
|
||||
page: pageNumber,
|
||||
type: 'highlight',
|
||||
rect: screenRectToPdf(
|
||||
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
|
||||
vp,
|
||||
),
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
props,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as HighlightAnnotation);
|
||||
canvas.setActiveObject(draft.object);
|
||||
canvas.requestRenderAll();
|
||||
},
|
||||
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||
if (annotation.type !== 'highlight') return;
|
||||
const highlight = annotation as HighlightAnnotation;
|
||||
const rect = pdfRectToScreen(highlight.rect, vp);
|
||||
const object = new fabric.Rect({
|
||||
left: rect.x,
|
||||
top: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
});
|
||||
configure(object);
|
||||
object.set({
|
||||
id: highlight.id,
|
||||
fill: highlight.props.color,
|
||||
opacity: highlight.props.opacity,
|
||||
});
|
||||
canvas.add(object);
|
||||
},
|
||||
};
|
||||
|
|
@ -6,6 +6,12 @@ import type { Annotation, ImageAnnotation, ImageProps } from '../../../lib/annot
|
|||
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
|
||||
type FabricAnnotationObject = fabric.FabricObject & {
|
||||
id?: string;
|
||||
annotationType?: string;
|
||||
annotationProps?: ImageProps;
|
||||
};
|
||||
|
||||
export const ImageTool: ToolHandler = {
|
||||
name: 'image',
|
||||
|
||||
|
|
@ -18,7 +24,7 @@ export const ImageTool: ToolHandler = {
|
|||
canvas.defaultCursor = 'default';
|
||||
},
|
||||
|
||||
onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||
onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||
if (e.target) return;
|
||||
|
||||
const storeState = useEditorStore.getState();
|
||||
|
|
@ -45,8 +51,8 @@ export const ImageTool: ToolHandler = {
|
|||
img.set({
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
transparentCorners: false,
|
||||
cornerColor: '#3b82f6',
|
||||
cornerStrokeColor: '#3b82f6',
|
||||
|
|
@ -55,15 +61,16 @@ export const ImageTool: ToolHandler = {
|
|||
padding: 5,
|
||||
});
|
||||
|
||||
(img as any).id = uuidv4();
|
||||
(img as any).annotationType = 'image';
|
||||
const annotatedImage = img as FabricAnnotationObject;
|
||||
annotatedImage.id = uuidv4();
|
||||
annotatedImage.annotationType = 'image';
|
||||
|
||||
const props: ImageProps = {
|
||||
ref: pendingImage.ref,
|
||||
naturalWidth: pendingImage.width,
|
||||
naturalHeight: pendingImage.height
|
||||
};
|
||||
(img as any).annotationProps = props;
|
||||
annotatedImage.annotationProps = props;
|
||||
|
||||
canvas.add(img);
|
||||
canvas.setActiveObject(img);
|
||||
|
|
@ -78,7 +85,7 @@ export const ImageTool: ToolHandler = {
|
|||
}, vp);
|
||||
|
||||
const annotation: ImageAnnotation = {
|
||||
id: (img as any).id,
|
||||
id: annotatedImage.id as string,
|
||||
page: pageNumber,
|
||||
type: 'image',
|
||||
rect: pdfRect,
|
||||
|
|
@ -97,7 +104,7 @@ export const ImageTool: ToolHandler = {
|
|||
},
|
||||
|
||||
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||
if (annotation.type !== 'image') return null;
|
||||
if (annotation.type !== 'image') return;
|
||||
const imgAnn = annotation as ImageAnnotation;
|
||||
const screenRect = pdfRectToScreen(imgAnn.rect, vp);
|
||||
|
||||
|
|
@ -107,24 +114,27 @@ export const ImageTool: ToolHandler = {
|
|||
img = await fabric.Image.fromURL(url);
|
||||
} catch (e) {
|
||||
console.error("Failed to load asset", e);
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
const annotatedImage = img as FabricAnnotationObject;
|
||||
annotatedImage.id = imgAnn.id;
|
||||
annotatedImage.annotationType = 'image';
|
||||
annotatedImage.annotationProps = imgAnn.props;
|
||||
img.set({
|
||||
left: screenRect.x,
|
||||
top: screenRect.y,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
scaleX: screenRect.width / img.width!,
|
||||
scaleY: screenRect.height / img.height!,
|
||||
id: imgAnn.id,
|
||||
annotationType: 'image',
|
||||
annotationProps: imgAnn.props,
|
||||
transparentCorners: false,
|
||||
cornerColor: '#3b82f6',
|
||||
cornerStrokeColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
cornerSize: 8,
|
||||
padding: 5,
|
||||
} as any);
|
||||
});
|
||||
|
||||
canvas.add(img);
|
||||
}
|
||||
|
|
|
|||
210
frontend/src/features/editor/tools/ShapeTool.ts
Normal file
210
frontend/src/features/editor/tools/ShapeTool.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import * as fabric from 'fabric';
|
||||
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 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>();
|
||||
|
||||
function pointer(event: PointerEvent, canvas: fabric.Canvas) {
|
||||
return event.scenePoint ?? canvas.getScenePoint(event.e);
|
||||
}
|
||||
|
||||
function style(object: fabric.FabricObject) {
|
||||
object.set({
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
transparentCorners: false,
|
||||
cornerColor: '#3b82f6',
|
||||
cornerStrokeColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
cornerSize: 8,
|
||||
});
|
||||
}
|
||||
|
||||
function arrowGeometry(start: { x: number; y: number }, end: { x: number; y: number }) {
|
||||
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
||||
const length = Math.min(12, Math.max(5, Math.hypot(end.x - start.x, end.y - start.y) * 0.2));
|
||||
const left = {
|
||||
x: end.x - length * Math.cos(angle - Math.PI / 6),
|
||||
y: end.y - length * Math.sin(angle - Math.PI / 6),
|
||||
};
|
||||
const right = {
|
||||
x: end.x - length * Math.cos(angle + Math.PI / 6),
|
||||
y: end.y - length * Math.sin(angle + Math.PI / 6),
|
||||
};
|
||||
const minX = Math.min(start.x, end.x, left.x, right.x);
|
||||
const minY = Math.min(start.y, end.y, left.y, right.y);
|
||||
const point = (value: { x: number; y: number }) => `${value.x - minX} ${value.y - minY}`;
|
||||
return {
|
||||
left: minX,
|
||||
top: minY,
|
||||
path: `M ${point(start)} L ${point(end)} M ${point(end)} L ${point(left)} M ${point(end)} L ${point(right)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function createArrow(start: { x: number; y: number }, end: { x: number; y: number }) {
|
||||
const geometry = arrowGeometry(start, end);
|
||||
return new fabric.Path(geometry.path, {
|
||||
left: geometry.left,
|
||||
top: geometry.top,
|
||||
fill: 'transparent',
|
||||
stroke: '#111827',
|
||||
strokeWidth: 2,
|
||||
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 };
|
||||
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);
|
||||
return new fabric.Rect({ ...common, width: 0, height: 0 });
|
||||
}
|
||||
|
||||
function updateObject(draft: Draft, current: { x: number; y: number }, canvas: fabric.Canvas) {
|
||||
const { object, start, kind } = draft;
|
||||
if (kind === 'arrow') {
|
||||
const replacement = createArrow(start, current);
|
||||
style(replacement);
|
||||
canvas.remove(object);
|
||||
canvas.add(replacement);
|
||||
draft.object = replacement;
|
||||
return;
|
||||
}
|
||||
const width = current.x - start.x;
|
||||
const height = current.y - start.y;
|
||||
if (kind === 'line') {
|
||||
object.set({ x2: width, y2: height });
|
||||
} else {
|
||||
object.set({
|
||||
left: Math.min(start.x, current.x),
|
||||
top: Math.min(start.y, current.y),
|
||||
...(kind === 'ellipse'
|
||||
? { rx: Math.abs(width) / 2, ry: Math.abs(height) / 2 }
|
||||
: { width: Math.abs(width), height: Math.abs(height) }),
|
||||
});
|
||||
}
|
||||
object.setCoords();
|
||||
}
|
||||
|
||||
export const ShapeTool: ToolHandler = {
|
||||
name: 'shape',
|
||||
onActivate: (canvas) => {
|
||||
canvas.defaultCursor = 'crosshair';
|
||||
canvas.selection = false;
|
||||
},
|
||||
onDeactivate: (canvas) => {
|
||||
const draft = drafts.get(canvas);
|
||||
if (draft) canvas.remove(draft.object);
|
||||
drafts.delete(canvas);
|
||||
canvas.defaultCursor = 'default';
|
||||
},
|
||||
onPointerDown: (event: PointerEvent, canvas) => {
|
||||
if (event.target) return;
|
||||
const start = pointer(event, canvas);
|
||||
const kind = useEditorStore.getState().activeShapeKind;
|
||||
const object = createObject(kind, start);
|
||||
style(object);
|
||||
drafts.set(canvas, { object, start, kind });
|
||||
canvas.add(object);
|
||||
},
|
||||
onPointerMove: (event: PointerEvent, canvas) => {
|
||||
const draft = drafts.get(canvas);
|
||||
if (!draft) return;
|
||||
updateObject(draft, pointer(event, canvas), canvas);
|
||||
canvas.requestRenderAll();
|
||||
},
|
||||
onPointerUp: (event: PointerEvent, canvas, vp: ViewportParams, pageNumber) => {
|
||||
const draft = drafts.get(canvas);
|
||||
if (!draft) return;
|
||||
updateObject(draft, pointer(event, canvas), canvas);
|
||||
const bounds = draft.object.getBoundingRect();
|
||||
drafts.delete(canvas);
|
||||
if (bounds.width < 2 && bounds.height < 2) {
|
||||
canvas.remove(draft.object);
|
||||
return;
|
||||
}
|
||||
const id = uuidv4();
|
||||
draft.object.set({ id });
|
||||
const props: ShapeProps = {
|
||||
kind: draft.kind,
|
||||
strokeColor: '#111827',
|
||||
fillColor: 'transparent',
|
||||
strokeWidth: 2,
|
||||
};
|
||||
if (draft.kind === 'line' || draft.kind === 'arrow') {
|
||||
const width = Math.max(bounds.width, 1);
|
||||
const height = Math.max(bounds.height, 1);
|
||||
const current = pointer(event, canvas);
|
||||
props.start = [
|
||||
(draft.start.x - bounds.left) / width,
|
||||
(draft.start.y - bounds.top) / height,
|
||||
];
|
||||
props.end = [
|
||||
(current.x - bounds.left) / width,
|
||||
(current.y - bounds.top) / height,
|
||||
];
|
||||
}
|
||||
useEditorStore.getState().addAnnotation({
|
||||
id,
|
||||
page: pageNumber,
|
||||
type: 'shape',
|
||||
rect: screenRectToPdf(
|
||||
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
|
||||
vp,
|
||||
),
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
props,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as ShapeAnnotation);
|
||||
canvas.setActiveObject(draft.object);
|
||||
canvas.requestRenderAll();
|
||||
},
|
||||
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||
if (annotation.type !== 'shape') return;
|
||||
const shape = annotation as ShapeAnnotation;
|
||||
const rect = pdfRectToScreen(shape.rect, vp);
|
||||
const props = shape.props;
|
||||
const common = {
|
||||
fill: props.fillColor === 'transparent' ? 'transparent' : props.fillColor,
|
||||
stroke: props.strokeColor,
|
||||
strokeWidth: props.strokeWidth,
|
||||
};
|
||||
let object: fabric.FabricObject;
|
||||
if (props.kind === 'ellipse') {
|
||||
object = new fabric.Ellipse({ ...common, left: rect.x, top: rect.y, rx: rect.width / 2, ry: rect.height / 2 });
|
||||
} else if (props.kind === 'line') {
|
||||
const start = props.start ?? [0, 0];
|
||||
const end = props.end ?? [1, 1];
|
||||
const startPoint = { x: rect.x + start[0] * rect.width, y: rect.y + start[1] * rect.height };
|
||||
const endPoint = { x: rect.x + end[0] * rect.width, y: rect.y + end[1] * rect.height };
|
||||
const left = Math.min(startPoint.x, endPoint.x);
|
||||
const top = Math.min(startPoint.y, endPoint.y);
|
||||
object = new fabric.Line(
|
||||
[startPoint.x - left, startPoint.y - top, endPoint.x - left, endPoint.y - top],
|
||||
{ ...common, left, top },
|
||||
);
|
||||
} else if (props.kind === 'arrow') {
|
||||
const start = props.start ?? [0, 0];
|
||||
const end = props.end ?? [1, 1];
|
||||
const startPoint = { x: rect.x + start[0] * rect.width, y: rect.y + start[1] * rect.height };
|
||||
const endPoint = { x: rect.x + end[0] * rect.width, y: rect.y + end[1] * rect.height };
|
||||
object = createArrow(startPoint, endPoint);
|
||||
object.set(common);
|
||||
} else {
|
||||
object = new fabric.Rect({ ...common, left: rect.x, top: rect.y, width: rect.width, height: rect.height });
|
||||
}
|
||||
style(object);
|
||||
object.set({ id: shape.id });
|
||||
canvas.add(object);
|
||||
},
|
||||
};
|
||||
|
|
@ -17,6 +17,10 @@ const FONTS = [
|
|||
'Caveat'
|
||||
];
|
||||
|
||||
interface AssetUploadResponse {
|
||||
ref: string;
|
||||
}
|
||||
|
||||
export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) {
|
||||
const [tab, setTab] = useState<'type' | 'draw'>('type');
|
||||
const [text, setText] = useState('John Doe');
|
||||
|
|
@ -116,7 +120,7 @@ export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) {
|
|||
const formData = new FormData();
|
||||
formData.append('file', blob, 'signature.png');
|
||||
|
||||
const data = await api.upload<any>(`/documents/${documentId}/assets`, formData);
|
||||
const data = await api.upload<AssetUploadResponse>(`/documents/${documentId}/assets`, formData);
|
||||
|
||||
onConfirm({
|
||||
mode: 'draw',
|
||||
|
|
|
|||
|
|
@ -6,11 +6,18 @@ import type { Annotation, SignatureAnnotation, SignatureDrawProps, SignatureType
|
|||
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
|
||||
type SignatureProps = SignatureDrawProps | SignatureTypeProps;
|
||||
type FabricSignatureObject = fabric.FabricObject & {
|
||||
id?: string;
|
||||
annotationType?: string;
|
||||
annotationProps?: SignatureProps;
|
||||
};
|
||||
|
||||
let previewObj: fabric.FabricObject | null = null;
|
||||
let currentPreviewCanvas: fabric.Canvas | null = null;
|
||||
let isCreatingPreview = false;
|
||||
|
||||
const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: any) => {
|
||||
const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: SignatureProps) => {
|
||||
isCreatingPreview = true;
|
||||
const storeState = useEditorStore.getState();
|
||||
if (props.mode === 'draw') {
|
||||
|
|
@ -23,11 +30,13 @@ const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: a
|
|||
}
|
||||
img.set({ originX: 'center', originY: 'center', opacity: 0.5, evented: false });
|
||||
previewObj = img;
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
previewObj = null;
|
||||
}
|
||||
} else {
|
||||
previewObj = new fabric.Text(props.text, {
|
||||
fontFamily: props.fontFamily,
|
||||
fontSize: 48 * vp.scale * (vp.dpr || 1),
|
||||
fontSize: 48 * vp.scale,
|
||||
fill: props.color,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
|
|
@ -61,7 +70,7 @@ export const SignatureTool: ToolHandler = {
|
|||
isCreatingPreview = false;
|
||||
},
|
||||
|
||||
onPointerMove: (e: any, canvas: fabric.Canvas, vp: ViewportParams, _pageNumber: number) => {
|
||||
onPointerMove: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||
const props = useEditorStore.getState().pendingSignatureProps;
|
||||
if (!props) return;
|
||||
|
||||
|
|
@ -86,7 +95,7 @@ export const SignatureTool: ToolHandler = {
|
|||
}
|
||||
},
|
||||
|
||||
onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||
onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||
if (e.target && e.target !== previewObj) return;
|
||||
|
||||
const storeState = useEditorStore.getState();
|
||||
|
|
@ -120,7 +129,7 @@ export const SignatureTool: ToolHandler = {
|
|||
originY: 'center',
|
||||
});
|
||||
// We set id on the object so it can be identified
|
||||
(img as any).id = uuidv4();
|
||||
(img as FabricSignatureObject).id = uuidv4();
|
||||
fabricObj = img;
|
||||
} catch (err) {
|
||||
console.error("Failed to load signature image", err);
|
||||
|
|
@ -132,12 +141,12 @@ export const SignatureTool: ToolHandler = {
|
|||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
fontFamily: typeProps.fontFamily,
|
||||
fontSize: 48 * vp.scale * (vp.dpr || 1),
|
||||
fontSize: 48 * vp.scale,
|
||||
fill: typeProps.color,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
});
|
||||
(textObj as any).id = uuidv4();
|
||||
(textObj as FabricSignatureObject).id = uuidv4();
|
||||
fabricObj = textObj;
|
||||
}
|
||||
|
||||
|
|
@ -150,8 +159,9 @@ export const SignatureTool: ToolHandler = {
|
|||
padding: 5,
|
||||
});
|
||||
|
||||
(fabricObj as any).annotationType = 'signature';
|
||||
(fabricObj as any).annotationProps = props;
|
||||
const annotatedObject = fabricObj as FabricSignatureObject;
|
||||
annotatedObject.annotationType = 'signature';
|
||||
annotatedObject.annotationProps = props;
|
||||
|
||||
canvas.add(fabricObj);
|
||||
canvas.setActiveObject(fabricObj);
|
||||
|
|
@ -167,7 +177,7 @@ export const SignatureTool: ToolHandler = {
|
|||
}, vp);
|
||||
|
||||
const annotation: SignatureAnnotation = {
|
||||
id: (fabricObj as any).id,
|
||||
id: annotatedObject.id as string,
|
||||
page: pageNumber,
|
||||
type: 'signature',
|
||||
rect: pdfRect,
|
||||
|
|
@ -186,7 +196,7 @@ export const SignatureTool: ToolHandler = {
|
|||
},
|
||||
|
||||
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||
if (annotation.type !== 'signature') return null;
|
||||
if (annotation.type !== 'signature') return;
|
||||
const sigAnn = annotation as SignatureAnnotation;
|
||||
const screenRect = pdfRectToScreen(sigAnn.rect, vp);
|
||||
|
||||
|
|
@ -199,19 +209,23 @@ export const SignatureTool: ToolHandler = {
|
|||
img.set({
|
||||
left: screenRect.x,
|
||||
top: screenRect.y,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
scaleX: screenRect.width / img.width!,
|
||||
scaleY: screenRect.height / img.height!,
|
||||
});
|
||||
fabricObj = img;
|
||||
} catch (e) {
|
||||
console.error("Failed to load signature asset", e);
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const typeProps = sigAnn.props as SignatureTypeProps;
|
||||
fabricObj = new fabric.Text(typeProps.text, {
|
||||
left: screenRect.x,
|
||||
top: screenRect.y,
|
||||
originX: 'left',
|
||||
originY: 'top',
|
||||
fontFamily: typeProps.fontFamily,
|
||||
fill: typeProps.color,
|
||||
});
|
||||
|
|
@ -222,17 +236,18 @@ export const SignatureTool: ToolHandler = {
|
|||
});
|
||||
}
|
||||
|
||||
const annotatedObject = fabricObj as FabricSignatureObject;
|
||||
annotatedObject.id = sigAnn.id;
|
||||
annotatedObject.annotationType = 'signature';
|
||||
annotatedObject.annotationProps = sigAnn.props;
|
||||
fabricObj.set({
|
||||
id: sigAnn.id,
|
||||
annotationType: 'signature',
|
||||
annotationProps: sigAnn.props,
|
||||
transparentCorners: false,
|
||||
cornerColor: '#3b82f6',
|
||||
cornerStrokeColor: '#3b82f6',
|
||||
borderColor: '#3b82f6',
|
||||
cornerSize: 8,
|
||||
padding: 5,
|
||||
} as any);
|
||||
});
|
||||
|
||||
canvas.add(fabricObj);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import { useEditorStore } from '../store';
|
||||
import type { ToolHandler } from '../../../lib/annotations/registry';
|
||||
import type { Annotation, TextAnnotation } from '../../../lib/annotations/types';
|
||||
import { screenToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
|
||||
import type { ViewportParams } from '../../../lib/coords';
|
||||
|
||||
type FabricTextObject = fabric.Textbox & {
|
||||
id?: string;
|
||||
customHeight?: number;
|
||||
};
|
||||
|
||||
export const TextTool: ToolHandler = {
|
||||
name: 'text',
|
||||
|
||||
|
|
@ -18,7 +23,7 @@ export const TextTool: ToolHandler = {
|
|||
canvas.defaultCursor = 'default';
|
||||
},
|
||||
|
||||
onPointerDown: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||
onPointerDown: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||
// If we clicked on an existing object, don't create a new one
|
||||
if (e.target) return;
|
||||
|
||||
|
|
@ -39,7 +44,7 @@ export const TextTool: ToolHandler = {
|
|||
top: pointer.y,
|
||||
width: 150 * vp.scale,
|
||||
fontFamily: fontFamily,
|
||||
fontSize: fontSize * vp.scale * (vp.dpr || 1),
|
||||
fontSize: fontSize * vp.scale,
|
||||
fontWeight: isBold ? 'bold' : 'normal',
|
||||
fontStyle: isItalic ? 'italic' : 'normal',
|
||||
fill: color,
|
||||
|
|
@ -55,12 +60,13 @@ export const TextTool: ToolHandler = {
|
|||
});
|
||||
|
||||
// Allow manual height control
|
||||
(textbox as any).customHeight = textbox.height;
|
||||
const annotatedTextbox = textbox as FabricTextObject;
|
||||
annotatedTextbox.customHeight = textbox.height;
|
||||
const originalInitDimensions = textbox.initDimensions.bind(textbox);
|
||||
textbox.initDimensions = function() {
|
||||
originalInitDimensions();
|
||||
if ((this as any).customHeight !== undefined) {
|
||||
this.height = (this as any).customHeight;
|
||||
if ((this as FabricTextObject).customHeight !== undefined) {
|
||||
this.height = (this as FabricTextObject).customHeight as number;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -94,7 +100,7 @@ export const TextTool: ToolHandler = {
|
|||
const w = textbox.width! * textbox.scaleX!;
|
||||
const h = textbox.height! * textbox.scaleY!;
|
||||
|
||||
(textbox as any).customHeight = h;
|
||||
annotatedTextbox.customHeight = h;
|
||||
|
||||
textbox.set({
|
||||
width: w,
|
||||
|
|
@ -106,24 +112,27 @@ export const TextTool: ToolHandler = {
|
|||
});
|
||||
|
||||
const newId = uuidv4();
|
||||
(textbox as any).id = newId;
|
||||
annotatedTextbox.id = newId;
|
||||
|
||||
canvas.add(textbox);
|
||||
canvas.setActiveObject(textbox);
|
||||
textbox.enterEditing();
|
||||
|
||||
// Add to store immediately so the toolbar shows up instantly
|
||||
const pt = screenToPdf({ x: textbox.left!, y: textbox.top! }, vp);
|
||||
const initialBounds = textbox.getBoundingRect();
|
||||
const newAnn: TextAnnotation = {
|
||||
id: newId,
|
||||
page: pageNumber,
|
||||
type: 'text',
|
||||
rect: {
|
||||
x: pt.x,
|
||||
y: pt.y,
|
||||
width: textbox.width! / (vp.scale * (vp.dpr || 1)),
|
||||
height: textbox.height! / (vp.scale * (vp.dpr || 1))
|
||||
},
|
||||
rect: screenRectToPdf(
|
||||
{
|
||||
x: initialBounds.left,
|
||||
y: initialBounds.top,
|
||||
width: initialBounds.width,
|
||||
height: initialBounds.height,
|
||||
},
|
||||
vp,
|
||||
),
|
||||
rotation: 0,
|
||||
z: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
|
|
@ -143,41 +152,49 @@ export const TextTool: ToolHandler = {
|
|||
storeState.setDraftAnnotation(newAnn);
|
||||
|
||||
textbox.on('editing:exited', () => {
|
||||
// Clear draft
|
||||
useEditorStore.getState().setDraftAnnotation(null);
|
||||
|
||||
if (!textbox.text || textbox.text.trim() === '') {
|
||||
useEditorStore.getState().setDraftAnnotation(null);
|
||||
canvas.remove(textbox);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get fresh annotation in case it was modified (e.g. bold/italic via toolbar)
|
||||
const currentAnn = (useEditorStore.getState().annotations.find(a => a.id === newId) as TextAnnotation) || newAnn;
|
||||
|
||||
// Update the text and push to main store
|
||||
currentAnn.props.text = textbox.text;
|
||||
const state = useEditorStore.getState();
|
||||
const currentAnn = (state.annotations.find(a => a.id === newId) as TextAnnotation | undefined)
|
||||
?? state.draftAnnotation as TextAnnotation | null
|
||||
?? newAnn;
|
||||
const bounds = textbox.getBoundingRect();
|
||||
const nextAnnotation: TextAnnotation = {
|
||||
...currentAnn,
|
||||
rect: screenRectToPdf(
|
||||
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
|
||||
vp,
|
||||
),
|
||||
props: { ...currentAnn.props, text: textbox.text },
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save inline styles if any exist
|
||||
if ((textbox as any).styles && Object.keys((textbox as any).styles).length > 0) {
|
||||
currentAnn.props.styles = JSON.parse(JSON.stringify((textbox as any).styles));
|
||||
if (textbox.styles && Object.keys(textbox.styles).length > 0) {
|
||||
nextAnnotation.props.styles = JSON.parse(JSON.stringify(textbox.styles));
|
||||
}
|
||||
|
||||
useEditorStore.getState().addAnnotation(currentAnn);
|
||||
state.setDraftAnnotation(null);
|
||||
state.addAnnotation(nextAnnotation);
|
||||
});
|
||||
},
|
||||
|
||||
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||
const textAnn = annotation as TextAnnotation;
|
||||
const pt = pdfRectToScreen(textAnn.rect, vp);
|
||||
const boxHeight = textAnn.rect.height * vp.scale * (vp.dpr || 1);
|
||||
const boxHeight = textAnn.rect.height * vp.scale;
|
||||
|
||||
const textbox = new fabric.Textbox(textAnn.props.text, {
|
||||
left: pt.x,
|
||||
top: pt.y,
|
||||
width: textAnn.rect.width * vp.scale * (vp.dpr || 1),
|
||||
width: textAnn.rect.width * vp.scale,
|
||||
height: boxHeight,
|
||||
fontFamily: textAnn.props.fontFamily,
|
||||
fontSize: textAnn.props.fontSize * vp.scale * (vp.dpr || 1),
|
||||
fontSize: textAnn.props.fontSize * vp.scale,
|
||||
fontWeight: textAnn.props.bold ? 'bold' : 'normal',
|
||||
fontStyle: textAnn.props.italic ? 'italic' : 'normal',
|
||||
fill: textAnn.props.color,
|
||||
|
|
@ -194,12 +211,13 @@ export const TextTool: ToolHandler = {
|
|||
});
|
||||
|
||||
// Allow manual height control
|
||||
(textbox as any).customHeight = boxHeight;
|
||||
const annotatedTextbox = textbox as FabricTextObject;
|
||||
annotatedTextbox.customHeight = boxHeight;
|
||||
const originalInitDimensions = textbox.initDimensions.bind(textbox);
|
||||
textbox.initDimensions = function() {
|
||||
originalInitDimensions();
|
||||
if ((this as any).customHeight !== undefined) {
|
||||
this.height = (this as any).customHeight;
|
||||
if ((this as FabricTextObject).customHeight !== undefined) {
|
||||
this.height = (this as FabricTextObject).customHeight as number;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -232,7 +250,7 @@ export const TextTool: ToolHandler = {
|
|||
const w = textbox.width! * textbox.scaleX!;
|
||||
const h = textbox.height! * textbox.scaleY!;
|
||||
|
||||
(textbox as any).customHeight = h;
|
||||
annotatedTextbox.customHeight = h;
|
||||
|
||||
textbox.set({
|
||||
width: w,
|
||||
|
|
@ -248,16 +266,16 @@ export const TextTool: ToolHandler = {
|
|||
const currentAnn = useEditorStore.getState().annotations.find(a => a.id === annotation.id) as TextAnnotation | undefined;
|
||||
if (!currentAnn) return;
|
||||
|
||||
const updates: any = { text: textbox.text };
|
||||
if ((textbox as any).styles && Object.keys((textbox as any).styles).length > 0) {
|
||||
updates.styles = JSON.parse(JSON.stringify((textbox as any).styles));
|
||||
const updates: Partial<TextAnnotation['props']> = { text: textbox.text };
|
||||
if (textbox.styles && Object.keys(textbox.styles).length > 0) {
|
||||
updates.styles = JSON.parse(JSON.stringify(textbox.styles)) as Record<string, unknown>;
|
||||
}
|
||||
useEditorStore.getState().updateAnnotation(annotation.id, {
|
||||
props: { ...currentAnn.props, ...updates }
|
||||
});
|
||||
});
|
||||
|
||||
(textbox as any).id = annotation.id;
|
||||
annotatedTextbox.id = annotation.id;
|
||||
canvas.add(textbox);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,12 +3,16 @@ import { TextTool } from './TextTool';
|
|||
import { SignatureTool } from './SignatureTool';
|
||||
import { ImageTool } from './ImageTool';
|
||||
import { DrawTool } from './DrawTool';
|
||||
import { HighlightTool } from './HighlightTool';
|
||||
import { ShapeTool } from './ShapeTool';
|
||||
|
||||
// Register all tools here
|
||||
registerTool(TextTool);
|
||||
registerTool(SignatureTool);
|
||||
registerTool(ImageTool);
|
||||
registerTool(DrawTool);
|
||||
registerTool(HighlightTool);
|
||||
registerTool(ShapeTool);
|
||||
|
||||
// SelectTool implementation
|
||||
registerTool({
|
||||
|
|
|
|||
|
|
@ -1,75 +1,135 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useEditorStore } from './store';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { api, ApiRequestError } from '../../lib/api/client';
|
||||
import type { Annotation } from '../../lib/annotations/types';
|
||||
import { useEditorStore } from './store';
|
||||
|
||||
export function useAutosave() {
|
||||
const { documentId, annotations, setSaveStatus, setAnnotations } = useEditorStore();
|
||||
interface AnnotationResponse {
|
||||
data: Annotation[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface AnnotationUpdateResponse {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useAutosave(documentId: string | undefined) {
|
||||
const loadedDocumentId = useRef<string | null>(null);
|
||||
const skipNextSave = useRef(false);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
const isFirstLoad = useRef(true);
|
||||
const saveGeneration = useRef(0);
|
||||
const setDocumentId = useEditorStore((state) => state.setDocumentId);
|
||||
const setAnnotations = useEditorStore((state) => state.setAnnotations);
|
||||
const setAnnotationUpdatedAt = useEditorStore((state) => state.setAnnotationUpdatedAt);
|
||||
const setSaveStatus = useEditorStore((state) => state.setSaveStatus);
|
||||
const storeDocumentId = useEditorStore((state) => state.documentId);
|
||||
const annotations = useEditorStore((state) => state.annotations);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (!documentId) return;
|
||||
saveGeneration.current += 1;
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setDocumentId(documentId ?? null);
|
||||
loadedDocumentId.current = null;
|
||||
skipNextSave.current = true;
|
||||
}, [documentId, setDocumentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentId || storeDocumentId !== documentId) return;
|
||||
let active = true;
|
||||
setSaveStatus('loading');
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/documents/${documentId}/annotations`);
|
||||
if (!res.ok) throw new Error('Failed to load annotations');
|
||||
const data = await res.json();
|
||||
|
||||
if (active) {
|
||||
setAnnotations(data.data as Annotation[]);
|
||||
setSaveStatus('saved');
|
||||
isFirstLoad.current = false;
|
||||
const response = await api.get<AnnotationResponse>(`/documents/${documentId}/annotations`);
|
||||
if (!active || useEditorStore.getState().documentId !== documentId) return;
|
||||
skipNextSave.current = true;
|
||||
setAnnotations(response.data);
|
||||
setAnnotationUpdatedAt(response.updatedAt);
|
||||
loadedDocumentId.current = documentId;
|
||||
setSaveStatus('saved');
|
||||
} catch (error) {
|
||||
if (active && useEditorStore.getState().documentId === documentId) {
|
||||
console.error('Error loading annotations:', error);
|
||||
setSaveStatus('error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading annotations:', err);
|
||||
if (active) setSaveStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => { active = false; };
|
||||
}, [documentId, setAnnotations, setSaveStatus]);
|
||||
|
||||
// Debounced save
|
||||
useEffect(() => {
|
||||
// Don't save on the initial load!
|
||||
if (isFirstLoad.current) return;
|
||||
if (!documentId) return;
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [documentId, setAnnotationUpdatedAt, setAnnotations, setSaveStatus, storeDocumentId]);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (!documentId || useEditorStore.getState().documentId !== documentId || loadedDocumentId.current !== documentId) {
|
||||
return false;
|
||||
}
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
const generation = ++saveGeneration.current;
|
||||
const snapshot = useEditorStore.getState();
|
||||
const payload = {
|
||||
data: snapshot.annotations,
|
||||
baseUpdatedAt: snapshot.annotationUpdatedAt ?? undefined,
|
||||
};
|
||||
setSaveStatus('saving');
|
||||
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/documents/${documentId}/annotations`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: annotations
|
||||
})
|
||||
try {
|
||||
const response = await api.put<AnnotationUpdateResponse>(`/documents/${documentId}/annotations`, payload)
|
||||
.catch(async (error: unknown) => {
|
||||
if (error instanceof ApiRequestError && error.status === 409) {
|
||||
return api.put<AnnotationUpdateResponse>(`/documents/${documentId}/annotations`, {
|
||||
data: snapshot.annotations,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to save');
|
||||
if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) {
|
||||
setAnnotationUpdatedAt(response.updatedAt);
|
||||
setSaveStatus('saved');
|
||||
} catch (err) {
|
||||
console.error('Error saving annotations:', err);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) {
|
||||
console.error('Error saving annotations:', error);
|
||||
setSaveStatus('error');
|
||||
}
|
||||
}, 500); // 500ms debounce
|
||||
return false;
|
||||
}
|
||||
}, [documentId, setAnnotationUpdatedAt, setSaveStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentId || storeDocumentId !== documentId || loadedDocumentId.current !== documentId) return;
|
||||
if (skipNextSave.current) {
|
||||
skipNextSave.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveStatus('saving');
|
||||
if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
void flush();
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
||||
saveGeneration.current += 1;
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [annotations, documentId, setSaveStatus]);
|
||||
}, [annotations, documentId, flush, setSaveStatus, storeDocumentId]);
|
||||
|
||||
useEffect(() => () => {
|
||||
saveGeneration.current += 1;
|
||||
if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
|
||||
}, []);
|
||||
|
||||
return { flush };
|
||||
}
|
||||
|
|
|
|||
236
frontend/src/features/editor/versions/VersionPanel.tsx
Normal file
236
frontend/src/features/editor/versions/VersionPanel.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Clock3, RotateCcw, Save, Trash2, X } from 'lucide-react';
|
||||
import { api } from '../../../lib/api/client';
|
||||
import type { Annotation } from '../../../lib/annotations/types';
|
||||
import { useEditorStore } from '../store';
|
||||
|
||||
interface VersionMeta {
|
||||
id: string;
|
||||
documentId: string;
|
||||
label: string | null;
|
||||
kind: 'manual' | 'auto';
|
||||
createdAt: string;
|
||||
annotationCount: number;
|
||||
}
|
||||
|
||||
interface VersionListResponse {
|
||||
items: VersionMeta[];
|
||||
}
|
||||
|
||||
interface AnnotationResponse {
|
||||
data: Annotation[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface VersionPanelProps {
|
||||
documentId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatVersionDate(value: string) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function VersionPanel({ documentId, onClose }: VersionPanelProps) {
|
||||
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
||||
const [label, setLabel] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setAnnotations = useEditorStore((state) => state.setAnnotations);
|
||||
const setAnnotationUpdatedAt = useEditorStore((state) => state.setAnnotationUpdatedAt);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.get<VersionListResponse>(`/documents/${documentId}/versions`);
|
||||
setVersions(response.items);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to load checkpoints.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [documentId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void loadVersions(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadVersions]);
|
||||
|
||||
const createCheckpoint = async () => {
|
||||
setBusyId('create');
|
||||
setError(null);
|
||||
try {
|
||||
await api.post<VersionMeta>(`/documents/${documentId}/versions`, {
|
||||
label: label.trim() || null,
|
||||
kind: 'manual',
|
||||
});
|
||||
setLabel('');
|
||||
await loadVersions();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to create checkpoint.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreVersion = async (version: VersionMeta) => {
|
||||
if (!window.confirm(`Restore “${version.label || 'Automatic checkpoint'}”? Your current work will be saved first.`)) {
|
||||
return;
|
||||
}
|
||||
setBusyId(version.id);
|
||||
setError(null);
|
||||
try {
|
||||
const restored = await api.post<{ updatedAt: string }>(
|
||||
`/documents/${documentId}/versions/${version.id}/restore`,
|
||||
);
|
||||
const current = await api.get<AnnotationResponse>(`/documents/${documentId}/annotations`);
|
||||
setAnnotations(current.data);
|
||||
setAnnotationUpdatedAt(restored.updatedAt || current.updatedAt);
|
||||
await loadVersions();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to restore checkpoint.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteVersion = async (version: VersionMeta) => {
|
||||
if (!window.confirm('Delete this checkpoint permanently?')) return;
|
||||
setBusyId(version.id);
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/documents/${documentId}/versions/${version.id}`);
|
||||
setVersions((current) => current.filter((item) => item.id !== version.id));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to delete checkpoint.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 right-0 z-[80] flex w-full max-w-md flex-col border-l border-neutral-200 bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
|
||||
<Clock3 className="h-5 w-5 text-accent-600" />
|
||||
Checkpoints
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-neutral-500">Recover annotation work without changing the original PDF.</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="rounded-lg p-2 text-neutral-500 hover:bg-neutral-100" aria-label="Close checkpoints">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-neutral-200 bg-neutral-50 px-5 py-4">
|
||||
<label className="block text-xs font-semibold uppercase tracking-wide text-neutral-500" htmlFor="checkpoint-label">
|
||||
Save current work
|
||||
</label>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
id="checkpoint-label"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
maxLength={120}
|
||||
placeholder="Optional label"
|
||||
className="min-w-0 flex-1 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-accent-500 focus:ring-2 focus:ring-accent-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void createCheckpoint()}
|
||||
disabled={busyId !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-2 text-sm font-semibold text-white hover:bg-accent-700 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!window.confirm('Clear every annotation? A recovery checkpoint will be created first.')) return;
|
||||
setBusyId('clear');
|
||||
setError(null);
|
||||
try {
|
||||
await api.post(`/documents/${documentId}/versions`, { label: 'Before clear all', kind: 'auto' });
|
||||
setAnnotations([]);
|
||||
await loadVersions();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to clear annotations.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}}
|
||||
disabled={busyId !== null}
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold text-red-600 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Clear all annotations
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{isLoading ? (
|
||||
<div className="py-12 text-center text-sm text-neutral-500">Loading checkpoints…</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-neutral-300 px-5 py-10 text-center">
|
||||
<Clock3 className="mx-auto h-8 w-8 text-neutral-300" />
|
||||
<p className="mt-3 text-sm font-medium text-neutral-700">No checkpoints yet</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">Save one before a major edit or export.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{versions.map((version) => {
|
||||
const busy = busyId === version.id;
|
||||
return (
|
||||
<div key={version.id} className="rounded-xl border border-neutral-200 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-neutral-800">{version.label || 'Automatic checkpoint'}</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{formatVersionDate(version.createdAt)} · {version.annotationCount} annotation{version.annotationCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${version.kind === 'manual' ? 'bg-accent-100 text-accent-700' : 'bg-neutral-100 text-neutral-500'}`}>
|
||||
{version.kind}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void restoreVersion(version)}
|
||||
disabled={busyId !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 px-2.5 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{busy ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
{version.kind === 'manual' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteVersion(version)}
|
||||
disabled={busyId !== null}
|
||||
className="rounded-lg p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
aria-label="Delete checkpoint"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { formatDistanceToNow } from 'date-fns'
|
|||
import { ContextMenu } from './ContextMenu'
|
||||
|
||||
export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
|
||||
const { documents, selectedIds, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary()
|
||||
const { documents, selectedIds, isLoading, error, toggleSelection, deleteDocument, restoreDocument, updateDocument } = useLibrary()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null)
|
||||
|
|
@ -61,6 +61,16 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
|
|||
isSelected ? 'border-accent-500 shadow-md ring-2 ring-accent-500/50 scale-[1.02]' : 'hover:border-accent-300 dark:hover:border-accent-700'
|
||||
}`}
|
||||
>
|
||||
<label className="absolute right-3 top-3 z-20 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border border-white/80 bg-white/90 shadow-sm backdrop-blur transition-opacity group-hover:opacity-100 sm:opacity-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelection(doc.id)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="h-4 w-4 rounded border-neutral-300 text-accent-600 focus:ring-accent-500"
|
||||
aria-label={`Select ${doc.title}`}
|
||||
/>
|
||||
</label>
|
||||
{isSelected && (
|
||||
<div className="absolute top-3 left-3 z-20 w-6 h-6 bg-accent-500 rounded-full flex items-center justify-center shadow-md ring-2 ring-white">
|
||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
|
@ -127,9 +137,13 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
|
|||
}
|
||||
}
|
||||
}}
|
||||
onDelete={() => deleteDocument(contextMenu.docId)}
|
||||
onDelete={() => {
|
||||
if (window.confirm('Move this document to Trash?')) void deleteDocument(contextMenu.docId)
|
||||
}}
|
||||
onRestore={() => restoreDocument(contextMenu.docId)}
|
||||
onHardDelete={() => deleteDocument(contextMenu.docId, true)}
|
||||
onHardDelete={() => {
|
||||
if (window.confirm('Delete this document permanently? This cannot be undone.')) void deleteDocument(contextMenu.docId, true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -94,7 +94,9 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar
|
|||
Restore
|
||||
</button>
|
||||
<button
|
||||
onClick={() => bulkDelete(true)}
|
||||
onClick={() => {
|
||||
if (window.confirm('Delete the selected documents permanently? This cannot be undone.')) void bulkDelete(true)
|
||||
}}
|
||||
className="text-sm text-red-600 dark:text-red-400 hover:text-white hover:bg-red-500 font-bold px-3 py-1.5 rounded-full transition-colors ml-1"
|
||||
>
|
||||
Delete Forever
|
||||
|
|
@ -123,7 +125,9 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar
|
|||
|
||||
{currentTab === 'trash' && (
|
||||
<button
|
||||
onClick={() => emptyTrash()}
|
||||
onClick={() => {
|
||||
if (window.confirm('Empty Trash permanently? This cannot be undone.')) void emptyTrash()
|
||||
}}
|
||||
className="text-sm font-bold text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 transition-colors px-4 py-2 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Empty Trash
|
||||
|
|
|
|||
|
|
@ -1,88 +1,74 @@
|
|||
import { useRef, useState } from 'react'
|
||||
import { useLibrary } from './useLibrary'
|
||||
import { useRef, useState, type ReactNode } from 'react';
|
||||
import { useLibrary } from './useLibrary';
|
||||
import { UploadPickerContext, useUploadPicker } from './upload-context';
|
||||
|
||||
export const UploadArea = ({ children }: { children: React.ReactNode }) => {
|
||||
const { uploadDocument } = useLibrary()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
export function UploadTrigger({ children, className }: { children: ReactNode; className: string }) {
|
||||
const openPicker = useUploadPicker();
|
||||
return <button type="button" onClick={openPicker} className={className}>{children}</button>;
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
await processFiles(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
await processFiles(e.target.files)
|
||||
// Reset input so the same file can be selected again
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
export const UploadArea = ({ children }: { children: ReactNode }) => {
|
||||
const { uploadDocument } = useLibrary();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const processFiles = async (files: FileList) => {
|
||||
// For now, process one by one
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
if (file.type === 'application/pdf') {
|
||||
try {
|
||||
await uploadDocument(file)
|
||||
} catch (err) {
|
||||
console.error('Failed to upload', file.name, err)
|
||||
// Could show toast notification here
|
||||
}
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const file = files[index];
|
||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) continue;
|
||||
try {
|
||||
await uploadDocument(file);
|
||||
} catch (reason) {
|
||||
console.error('Failed to upload', file.name, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (event.dataTransfer.files.length > 0) await processFiles(event.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files.length > 0) await processFiles(event.target.files);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-screen"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
multiple
|
||||
/>
|
||||
|
||||
{/* Invisible overlay that appears when dragging to prevent flickering */}
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 z-50 bg-accent-500/10 dark:bg-accent-900/20 backdrop-blur-md border-4 border-dashed border-accent-400 dark:border-accent-500 rounded-3xl m-4 flex items-center justify-center transition-all pointer-events-none">
|
||||
<div className="bg-white/95 dark:bg-gray-800/95 px-10 py-8 rounded-3xl shadow-2xl shadow-accent-500/20 flex flex-col items-center transform scale-105 transition-transform duration-300 border border-white/50">
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-accent-100 to-accent-200 dark:from-accent-800 dark:to-accent-900 rounded-full flex items-center justify-center mb-6 shadow-inner">
|
||||
<svg className="w-10 h-10 text-accent-600 dark:text-accent-300 animate-bounce" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-3xl font-heading font-bold text-gray-900 dark:text-white tracking-tight">Drop PDFs here</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2 font-medium">Release to upload to your library</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<UploadPickerContext.Provider value={() => fileInputRef.current?.click()}>
|
||||
<div className="relative min-h-screen" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={(event) => void handleDrop(event)}>
|
||||
<input ref={fileInputRef} type="file" accept="application/pdf" onChange={(event) => void handleFileChange(event)} className="hidden" multiple />
|
||||
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{isDragging && (
|
||||
<div className="pointer-events-none absolute inset-0 z-50 m-4 flex items-center justify-center rounded-3xl border-4 border-dashed border-accent-400 bg-accent-500/10 backdrop-blur-md">
|
||||
<div className="flex flex-col items-center rounded-3xl border border-white/50 bg-white/95 px-10 py-8 shadow-2xl">
|
||||
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-accent-100 shadow-inner">
|
||||
<svg className="h-10 w-10 animate-bounce text-accent-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-3xl font-heading font-bold tracking-tight text-neutral-900">Drop PDFs here</h3>
|
||||
<p className="mt-2 font-medium text-neutral-500">Release to upload to your library</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</UploadPickerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
9
frontend/src/features/library/upload-context.ts
Normal file
9
frontend/src/features/library/upload-context.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const UploadPickerContext = createContext<(() => void) | null>(null);
|
||||
|
||||
export function useUploadPicker() {
|
||||
const openPicker = useContext(UploadPickerContext);
|
||||
if (!openPicker) throw new Error('useUploadPicker must be used inside UploadArea');
|
||||
return openPicker;
|
||||
}
|
||||
|
|
@ -28,6 +28,17 @@ interface LibraryState {
|
|||
updateDocument: (id: string, data: DocumentUpdateRequest) => Promise<void>
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown, fallback = 'Something went wrong.') {
|
||||
if (reason instanceof Error && reason.message) return reason.message;
|
||||
if (typeof reason === 'object' && reason !== null && 'error' in reason) {
|
||||
const error = reason.error;
|
||||
if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export const useLibrary = create<LibraryState>((set, get) => ({
|
||||
documents: [],
|
||||
total: 0,
|
||||
|
|
@ -56,8 +67,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
|||
try {
|
||||
const data = await libraryApi.getDocuments(query)
|
||||
set({ documents: data.items, total: data.total, isLoading: false })
|
||||
} catch (err: any) {
|
||||
set({ error: err.error?.message || err.message, isLoading: false })
|
||||
} catch (err: unknown) {
|
||||
set({ error: errorMessage(err), isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -66,8 +77,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
|||
try {
|
||||
const data = await libraryApi.getTrash()
|
||||
set({ documents: data.items, total: data.total, isLoading: false })
|
||||
} catch (err: any) {
|
||||
set({ error: err.error?.message || err.message, isLoading: false })
|
||||
} catch (err: unknown) {
|
||||
set({ error: errorMessage(err), isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -81,8 +92,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
|||
isLoading: false
|
||||
}))
|
||||
return newDoc
|
||||
} catch (err: any) {
|
||||
set({ error: err.error?.message || err.message, isLoading: false })
|
||||
} catch (err: unknown) {
|
||||
set({ error: errorMessage(err), isLoading: false })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
|
@ -97,8 +108,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
|||
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
|
||||
isLoading: false
|
||||
}))
|
||||
} catch (err: any) {
|
||||
set({ error: err.error?.message || err.message, isLoading: false })
|
||||
} catch (err: unknown) {
|
||||
set({ error: errorMessage(err), isLoading: false })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
|
@ -113,8 +124,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
|||
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
|
||||
isLoading: false
|
||||
}))
|
||||
} catch (err: any) {
|
||||
set({ error: err.error?.message || err.message, isLoading: false })
|
||||
} catch (err: unknown) {
|
||||
set({ error: errorMessage(err), isLoading: false })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
|
@ -138,8 +149,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
|||
selectedIds: new Set(),
|
||||
isLoading: false
|
||||
}))
|
||||
} catch (err: any) {
|
||||
set({ error: err.error?.message || err.message, isLoading: false })
|
||||
} catch (err: unknown) {
|
||||
set({ error: errorMessage(err), isLoading: false })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
|
@ -157,8 +168,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
|||
selectedIds: new Set(),
|
||||
isLoading: false
|
||||
}))
|
||||
} catch (err: any) {
|
||||
set({ error: err.error?.message || err.message, isLoading: false })
|
||||
} catch (err: unknown) {
|
||||
set({ error: errorMessage(err), isLoading: false })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
|
@ -173,8 +184,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
|||
selectedIds: new Set(),
|
||||
isLoading: false
|
||||
})
|
||||
} catch (err: any) {
|
||||
set({ error: err.error?.message || err.message, isLoading: false })
|
||||
} catch (err: unknown) {
|
||||
set({ error: errorMessage(err), isLoading: false })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
|
@ -187,8 +198,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
|||
documents: state.documents.map(d => d.id === id ? updatedDoc : d),
|
||||
isLoading: false
|
||||
}))
|
||||
} catch (err: any) {
|
||||
set({ error: err.error?.message || err.message, isLoading: false })
|
||||
} catch (err: unknown) {
|
||||
set({ error: errorMessage(err), isLoading: false })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@
|
|||
@import "@fontsource/plus-jakarta-sans/500.css";
|
||||
@import "@fontsource/plus-jakarta-sans/600.css";
|
||||
@import "@fontsource/plus-jakarta-sans/700.css";
|
||||
@import "@fontsource/allura/400.css";
|
||||
@import "@fontsource/caveat/400.css";
|
||||
@import "@fontsource/dancing-script/400.css";
|
||||
@import "@fontsource/great-vibes/400.css";
|
||||
@import "@fontsource/sacramento/400.css";
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
|
|
|
|||
|
|
@ -17,15 +17,19 @@ export interface ToolHandler {
|
|||
onDeactivate?: (canvas: Canvas) => void;
|
||||
|
||||
/** Called when the user presses down on the canvas. */
|
||||
onPointerDown?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
|
||||
onPointerDown?: (e: fabric.TPointerEventInfo, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void | Promise<void>;
|
||||
|
||||
/** Called when the user drags the pointer. */
|
||||
onPointerMove?: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void;
|
||||
onPointerUp?: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void;
|
||||
onPathCreated?: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void;
|
||||
onPointerMove?: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void | Promise<void>;
|
||||
onPointerUp?: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void | Promise<void>;
|
||||
onPathCreated?: (e: fabric.CanvasEvents['path:created'], canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void | Promise<void>;
|
||||
|
||||
// Fabric to/from Annotation serialization
|
||||
renderToFabric?: (annotation: Annotation, canvas: Canvas, viewportParams: ViewportParams) => void;
|
||||
renderToFabric?: (
|
||||
annotation: Annotation,
|
||||
canvas: Canvas,
|
||||
viewportParams: ViewportParams,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const registry = new Map<string, ToolHandler>();
|
||||
|
|
|
|||
|
|
@ -1,28 +1,125 @@
|
|||
import type { components } from '../../types/api';
|
||||
/**
|
||||
* Client annotation model.
|
||||
*
|
||||
* The API intentionally exposes the working layer as an opaque JSON array so
|
||||
* newer annotation types can round-trip through an older server. These types
|
||||
* describe the v1 tools used by this client; the generated API types still
|
||||
* describe the transport envelope and endpoint shapes.
|
||||
*/
|
||||
|
||||
export type TextProps = components['schemas']['TextProps'] & { highlightColor?: string | null, styles?: Record<string, any> | null };
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type TextAnnotation = Omit<components['schemas']['TextAnnotation'], 'props'> & {
|
||||
export interface AnnotationBase {
|
||||
id: string;
|
||||
page: number;
|
||||
type: string;
|
||||
rect: Rect;
|
||||
rotation?: number;
|
||||
z?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TextProps {
|
||||
text: string;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
color: string;
|
||||
align: 'left' | 'center' | 'right';
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
lineHeight: number;
|
||||
highlightColor?: string | null;
|
||||
styles?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface TextAnnotation extends AnnotationBase {
|
||||
type: 'text';
|
||||
props: TextProps;
|
||||
};
|
||||
export type DrawAnnotation = components['schemas']['DrawAnnotation'];
|
||||
export type SignatureAnnotation = components['schemas']['SignatureAnnotation'];
|
||||
export type ImageAnnotation = components['schemas']['ImageAnnotation'];
|
||||
export type HighlightAnnotation = components['schemas']['HighlightAnnotation'];
|
||||
export type ShapeAnnotation = components['schemas']['ShapeAnnotation'];
|
||||
}
|
||||
|
||||
export type Annotation =
|
||||
export interface DrawProps {
|
||||
paths: [number, number][];
|
||||
svgPath?: string;
|
||||
strokeColor: string;
|
||||
strokeWidth: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface DrawAnnotation extends AnnotationBase {
|
||||
type: 'draw';
|
||||
props: DrawProps;
|
||||
}
|
||||
|
||||
export interface SignatureDrawProps {
|
||||
mode: 'draw';
|
||||
ref: string;
|
||||
strokeColor?: string;
|
||||
}
|
||||
|
||||
export interface SignatureTypeProps {
|
||||
mode: 'type';
|
||||
text: string;
|
||||
fontFamily: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface SignatureAnnotation extends AnnotationBase {
|
||||
type: 'signature';
|
||||
props: SignatureDrawProps | SignatureTypeProps;
|
||||
}
|
||||
|
||||
export interface ImageProps {
|
||||
ref: string;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
}
|
||||
|
||||
export interface ImageAnnotation extends AnnotationBase {
|
||||
type: 'image';
|
||||
props: ImageProps;
|
||||
}
|
||||
|
||||
export interface HighlightProps {
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface HighlightAnnotation extends AnnotationBase {
|
||||
type: 'highlight';
|
||||
props: HighlightProps;
|
||||
}
|
||||
|
||||
export interface ShapeProps {
|
||||
kind: 'rect' | 'ellipse' | 'line' | 'arrow';
|
||||
strokeColor: string;
|
||||
fillColor: string;
|
||||
strokeWidth: number;
|
||||
/** Normalized endpoints, retained for line/arrow direction. */
|
||||
start?: [number, number];
|
||||
end?: [number, number];
|
||||
}
|
||||
|
||||
export interface ShapeAnnotation extends AnnotationBase {
|
||||
type: 'shape';
|
||||
props: ShapeProps;
|
||||
}
|
||||
|
||||
export interface UnknownAnnotation extends AnnotationBase {
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type Annotation =
|
||||
| TextAnnotation
|
||||
| DrawAnnotation
|
||||
| SignatureAnnotation
|
||||
| ImageAnnotation
|
||||
| HighlightAnnotation
|
||||
| ShapeAnnotation;
|
||||
|
||||
export type Rect = components['schemas']['Rect'];
|
||||
export type DrawProps = components['schemas']['DrawProps'] & { svgPath?: string };
|
||||
export type SignatureDrawProps = components['schemas']['SignatureDrawProps'];
|
||||
export type SignatureTypeProps = components['schemas']['SignatureTypeProps'];
|
||||
export type ImageProps = components['schemas']['ImageProps'];
|
||||
export type HighlightProps = components['schemas']['HighlightProps'];
|
||||
export type ShapeProps = components['schemas']['ShapeProps'];
|
||||
| ShapeAnnotation
|
||||
| UnknownAnnotation;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,21 @@ export class ApiRequestError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function toApiError(data: unknown, statusText: string): ApiError {
|
||||
const record = isRecord(data) ? data : {};
|
||||
const nested = isRecord(record.error) ? record.error : {};
|
||||
return {
|
||||
code: typeof nested.code === 'string' ? nested.code : 'unknown',
|
||||
message: typeof nested.message === 'string'
|
||||
? nested.message
|
||||
: typeof record.detail === 'string' ? record.detail : statusText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal fetch wrapper with shared behavior.
|
||||
*/
|
||||
|
|
@ -75,23 +90,19 @@ async function apiFetch<T>(
|
|||
}
|
||||
|
||||
// Parse response
|
||||
let data: any = {}
|
||||
let data: unknown = {}
|
||||
try {
|
||||
const text = await response.text()
|
||||
if (text) {
|
||||
data = JSON.parse(text)
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// If it's not JSON, we'll just fall back to the empty object
|
||||
}
|
||||
|
||||
// Handle error responses
|
||||
if (!response.ok) {
|
||||
const error: ApiError = {
|
||||
code: data.error?.code ?? 'unknown',
|
||||
message: data.error?.message ?? data.detail ?? response.statusText,
|
||||
}
|
||||
throw new ApiRequestError(response.status, error)
|
||||
throw new ApiRequestError(response.status, toApiError(data, response.statusText))
|
||||
}
|
||||
|
||||
return data as T
|
||||
|
|
@ -158,17 +169,15 @@ export const api = {
|
|||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let data: any = {}
|
||||
let data: unknown = {}
|
||||
try {
|
||||
const text = await response.text()
|
||||
if (text) data = JSON.parse(text)
|
||||
} catch (err) {}
|
||||
|
||||
const error: ApiError = {
|
||||
code: data.error?.code ?? 'unknown',
|
||||
message: data.error?.message ?? data.detail ?? response.statusText,
|
||||
} catch {
|
||||
data = {}
|
||||
}
|
||||
throw new ApiRequestError(response.status, error)
|
||||
|
||||
throw new ApiRequestError(response.status, toApiError(data, response.statusText))
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
|
|
|
|||
|
|
@ -92,4 +92,16 @@ describe('Coordinate Transforms', () => {
|
|||
expect(blS.x).toBeCloseTo(0, 2);
|
||||
expect(blS.y).toBeCloseTo(0, 2);
|
||||
});
|
||||
|
||||
it('keeps CSS geometry independent from device pixel ratio', () => {
|
||||
const standard = pdfToScreen(
|
||||
{ x: 120, y: 180 },
|
||||
{ scale: 1.5, rotation: 0, canonicalWidth: W, canonicalHeight: H, dpr: 1 },
|
||||
);
|
||||
const retina = pdfToScreen(
|
||||
{ x: 120, y: 180 },
|
||||
{ scale: 1.5, rotation: 0, canonicalWidth: W, canonicalHeight: H, dpr: 2 },
|
||||
);
|
||||
expect(retina).toEqual(standard);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,19 +5,18 @@ export interface PdfRect { x: number; y: number; width: number; height: number }
|
|||
export interface ScreenRect { x: number; y: number; width: number; height: number }
|
||||
|
||||
export interface ViewportParams {
|
||||
scale: number; // CSS Zoom level (1.0 = 100%)
|
||||
scale: number; // CSS zoom level (1.0 = 100%)
|
||||
rotation: number; // 0, 90, 180, 270 (clockwise)
|
||||
canonicalWidth: number; // Unrotated CropBox width in PDF points
|
||||
canonicalHeight: number;// Unrotated CropBox height in PDF points
|
||||
dpr?: number; // Device Pixel Ratio (defaults to 1)
|
||||
}
|
||||
|
||||
function getEffectiveScale(vp: ViewportParams): number {
|
||||
return vp.scale * (vp.dpr || 1);
|
||||
dpr?: number; // Backing-store density; never part of CSS geometry
|
||||
}
|
||||
|
||||
export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
|
||||
const s = getEffectiveScale(vp);
|
||||
// Screen points are CSS pixels. Device-pixel-ratio only controls the PDF.js
|
||||
// backing canvas; including it here would make Fabric objects overflow the
|
||||
// CSS overlay on retina displays.
|
||||
const s = vp.scale;
|
||||
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
|
||||
const { x, y } = p;
|
||||
|
||||
|
|
@ -33,7 +32,7 @@ export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
|
|||
}
|
||||
|
||||
export function screenToPdf(p: ScreenPoint, vp: ViewportParams): PdfPoint {
|
||||
const s = getEffectiveScale(vp);
|
||||
const s = vp.scale;
|
||||
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
|
||||
const sx = p.x / s;
|
||||
const sy = p.y / s;
|
||||
|
|
|
|||
|
|
@ -1,61 +1,113 @@
|
|||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import { PdfDocument } from '../features/editor/pages/PdfDocument'
|
||||
import { EditorToolbar } from '../features/editor/toolbar/EditorToolbar'
|
||||
import { useEditorStore } from '../features/editor/store'
|
||||
import { useAutosave } from '../features/editor/useAutosave'
|
||||
import '../features/editor/tools'
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { api } from '../lib/api/client';
|
||||
import { PdfDocument } from '../features/editor/pages/PdfDocument';
|
||||
import { EditorToolbar } from '../features/editor/toolbar/EditorToolbar';
|
||||
import { VersionPanel } from '../features/editor/versions/VersionPanel';
|
||||
import { useEditorStore } from '../features/editor/store';
|
||||
import { useAutosave } from '../features/editor/useAutosave';
|
||||
import '../features/editor/tools';
|
||||
|
||||
interface DocumentSummary {
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor page — PDF canvas workspace.
|
||||
*/
|
||||
export function EditorPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { setDocumentId } = useEditorStore()
|
||||
useAutosave()
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [title, setTitle] = useState('Untitled document');
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||
const undo = useEditorStore((state) => state.undo);
|
||||
const redo = useEditorStore((state) => state.redo);
|
||||
|
||||
const { flush } = useAutosave(id);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setDocumentId(id)
|
||||
}
|
||||
}, [id, setDocumentId])
|
||||
if (!id) return;
|
||||
let active = true;
|
||||
void api.get<DocumentSummary>(`/documents/${id}`).then((document) => {
|
||||
if (active) setTitle(document.title);
|
||||
}).catch(() => {
|
||||
if (active) setTitle('Untitled document');
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (!id) return <div>Invalid document ID</div>
|
||||
useEffect(() => {
|
||||
const handleHistoryShortcut = (event: KeyboardEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') return;
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) redo();
|
||||
else undo();
|
||||
};
|
||||
document.addEventListener('keydown', handleHistoryShortcut);
|
||||
return () => document.removeEventListener('keydown', handleHistoryShortcut);
|
||||
}, [redo, undo]);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setIsExporting(true);
|
||||
setExportError(null);
|
||||
try {
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
const saved = await flush();
|
||||
if (!saved && useEditorStore.getState().saveStatus === 'loading') {
|
||||
throw new Error('Please wait for the annotation layer to finish loading.');
|
||||
}
|
||||
const blob = await api.fetchBlob(`/documents/${id}/export`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ flatten: true }),
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = title.toLowerCase().endsWith('.pdf') ? title : `${title}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error ? reason.message : 'Unable to export this PDF.';
|
||||
setExportError(message);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [flush, id, title]);
|
||||
|
||||
if (!id) return <div className="p-8 text-center text-red-600">Invalid document ID.</div>;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-neutral-100 overflow-hidden">
|
||||
{/* Editor toolbar */}
|
||||
<header className="flex-none flex items-center justify-between border-b border-neutral-200 bg-white px-4 py-2 z-10 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-700"
|
||||
>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-neutral-100">
|
||||
<header className="z-40 flex flex-none items-center justify-between border-b border-neutral-200 bg-white px-4 py-2.5 shadow-sm">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Link to="/" className="rounded-md px-2 py-1.5 text-sm font-semibold text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-800">
|
||||
← Back
|
||||
</Link>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
Document {id}
|
||||
</span>
|
||||
<div className="h-5 w-px bg-neutral-200" />
|
||||
<span className="max-w-[min(50vw,32rem)] truncate text-sm font-semibold text-neutral-800" title={title}>{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-neutral-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
Saved
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
Export PDF
|
||||
<div className="flex items-center gap-3">
|
||||
{exportError && <span className="max-w-64 truncate text-xs font-medium text-red-600" title={exportError}>{exportError}</span>}
|
||||
<button type="button" onClick={() => void handleExport()} disabled={isExporting} className="rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-bold text-white transition-colors hover:bg-accent-700 disabled:cursor-wait disabled:opacity-60">
|
||||
{isExporting ? 'Exporting…' : 'Export PDF'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main workspace area */}
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
<EditorToolbar />
|
||||
<main className="relative flex-1 overflow-auto">
|
||||
<EditorToolbar onExport={() => void handleExport()} onOpenVersions={() => setIsVersionPanelOpen(true)} isExporting={isExporting} />
|
||||
<PdfDocument documentId={id} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isVersionPanelOpen && <VersionPanel documentId={id} onClose={() => setIsVersionPanelOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { LibraryHeader } from '../features/library/LibraryHeader'
|
||||
import { UploadArea } from '../features/library/UploadArea'
|
||||
import { UploadArea, UploadTrigger } from '../features/library/UploadArea'
|
||||
import { DocumentGrid } from '../features/library/DocumentGrid'
|
||||
import { useLibrary } from '../features/library/useLibrary'
|
||||
|
||||
|
|
@ -38,26 +38,12 @@ export function HomePage() {
|
|||
<p className="text-base sm:text-lg text-accent-100 font-medium mb-6 leading-relaxed">
|
||||
Upload any PDF to instantly annotate, sign, and modify it. Drop your files right here to get started.
|
||||
</p>
|
||||
<label className="cursor-pointer inline-flex items-center justify-center px-6 py-3 text-base font-bold rounded-full text-accent-900 bg-white hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-white/30 transition-all shadow-xl hover:-translate-y-1 active:translate-y-0">
|
||||
<UploadTrigger className="cursor-pointer inline-flex items-center justify-center px-6 py-3 text-base font-bold rounded-full text-accent-900 bg-white hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-white/30 transition-all shadow-xl hover:-translate-y-1 active:translate-y-0">
|
||||
<svg className="w-5 h-5 mr-2 text-accent-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<span>Browse Files</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const uploader = document.querySelector('input[type="file"][multiple]') as HTMLInputElement
|
||||
if (uploader && uploader !== e.target) {
|
||||
uploader.files = e.target.files
|
||||
const event = new Event('change', { bubbles: true })
|
||||
uploader.dispatchEvent(event)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</UploadTrigger>
|
||||
</div>
|
||||
|
||||
{/* Visual Graphic */}
|
||||
|
|
|
|||
848
frontend/src/types/api.d.ts
vendored
848
frontend/src/types/api.d.ts
vendored
|
|
@ -272,6 +272,46 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{id}/assets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Upload Asset
|
||||
* @description Upload a PNG, JPEG, GIF, or WebP annotation asset.
|
||||
*/
|
||||
post: operations["upload_asset_api_v1_documents__id__assets_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{id}/assets/{ref}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Asset
|
||||
* @description Serve an annotation asset to an authenticated session.
|
||||
*/
|
||||
get: operations["get_asset_api_v1_documents__id__assets__ref__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{document_id}/annotations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -290,6 +330,79 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{document_id}/versions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Versions */
|
||||
get: operations["list_versions_api_v1_documents__document_id__versions_get"];
|
||||
put?: never;
|
||||
/** Create Version */
|
||||
post: operations["create_version_api_v1_documents__document_id__versions_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{document_id}/versions/{version_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Version */
|
||||
get: operations["get_version_api_v1_documents__document_id__versions__version_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Delete Version */
|
||||
delete: operations["delete_version_api_v1_documents__document_id__versions__version_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{document_id}/versions/{version_id}/restore": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Restore Version */
|
||||
post: operations["restore_version_api_v1_documents__document_id__versions__version_id__restore_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{document_id}/export": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Export Document
|
||||
* @description Export the working layer or a selected version as a downloadable PDF.
|
||||
*/
|
||||
post: operations["export_document_api_v1_documents__document_id__export_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/health": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -310,28 +423,6 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/debug/verify-coords": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Verify Coords
|
||||
* @description Test endpoint for cross-engine coordinate verification.
|
||||
* Takes a canonical rect, draws it on the PDF using PyMuPDF,
|
||||
* and returns the flattened PDF.
|
||||
*/
|
||||
post: operations["verify_coords_api_v1_debug_verify_coords_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
|
|
@ -339,7 +430,9 @@ export interface components {
|
|||
/** AnnotationStateResponse */
|
||||
AnnotationStateResponse: {
|
||||
/** Data */
|
||||
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
|
||||
data: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
|
|
@ -349,7 +442,9 @@ export interface components {
|
|||
/** AnnotationStateUpdateRequest */
|
||||
AnnotationStateUpdateRequest: {
|
||||
/** Data */
|
||||
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
|
||||
data: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
/** Baseupdatedat */
|
||||
baseUpdatedAt?: string | null;
|
||||
};
|
||||
|
|
@ -368,6 +463,11 @@ export interface components {
|
|||
/** Loggedin */
|
||||
loggedIn: boolean;
|
||||
};
|
||||
/** Body_upload_asset_api_v1_documents__id__assets_post */
|
||||
Body_upload_asset_api_v1_documents__id__assets_post: {
|
||||
/** File */
|
||||
file: string;
|
||||
};
|
||||
/** Body_upload_document_api_v1_documents_post */
|
||||
Body_upload_document_api_v1_documents_post: {
|
||||
/** File */
|
||||
|
|
@ -425,402 +525,31 @@ export interface components {
|
|||
/** In Trash */
|
||||
in_trash?: boolean | null;
|
||||
};
|
||||
/** DrawAnnotation */
|
||||
DrawAnnotation: {
|
||||
/** ExportRequest */
|
||||
ExportRequest: {
|
||||
/** Versionid */
|
||||
versionId?: string | null;
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
* Flatten
|
||||
* @default true
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "draw";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
props: components["schemas"]["DrawProps"];
|
||||
};
|
||||
/** DrawProps */
|
||||
DrawProps: {
|
||||
/** Paths */
|
||||
paths: [
|
||||
number,
|
||||
number
|
||||
][];
|
||||
/**
|
||||
* Strokecolor
|
||||
* @default #000000
|
||||
*/
|
||||
strokeColor: string;
|
||||
/**
|
||||
* Strokewidth
|
||||
* @default 2
|
||||
*/
|
||||
strokeWidth: number;
|
||||
/**
|
||||
* Opacity
|
||||
* @default 1
|
||||
*/
|
||||
opacity: number;
|
||||
flatten: boolean;
|
||||
};
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
/** Detail */
|
||||
detail?: components["schemas"]["ValidationError"][];
|
||||
};
|
||||
/** HighlightAnnotation */
|
||||
HighlightAnnotation: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "highlight";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
props: components["schemas"]["HighlightProps"];
|
||||
};
|
||||
/** HighlightProps */
|
||||
HighlightProps: {
|
||||
/**
|
||||
* Color
|
||||
* @default #FFEB3B
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* Opacity
|
||||
* @default 0.3
|
||||
*/
|
||||
opacity: number;
|
||||
};
|
||||
/** ImageAnnotation */
|
||||
ImageAnnotation: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "image";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
props: components["schemas"]["ImageProps"];
|
||||
};
|
||||
/** ImageProps */
|
||||
ImageProps: {
|
||||
/** Ref */
|
||||
ref: string;
|
||||
/** Naturalwidth */
|
||||
naturalWidth: number;
|
||||
/** Naturalheight */
|
||||
naturalHeight: number;
|
||||
};
|
||||
/** LoginRequest */
|
||||
LoginRequest: {
|
||||
/** Password */
|
||||
password: string;
|
||||
};
|
||||
/** Rect */
|
||||
Rect: {
|
||||
/** X */
|
||||
x: number;
|
||||
/** Y */
|
||||
y: number;
|
||||
/** Width */
|
||||
width: number;
|
||||
/** Height */
|
||||
height: number;
|
||||
};
|
||||
/** SetupRequest */
|
||||
SetupRequest: {
|
||||
/** Password */
|
||||
password: string;
|
||||
};
|
||||
/** ShapeAnnotation */
|
||||
ShapeAnnotation: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "shape";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
props: components["schemas"]["ShapeProps"];
|
||||
};
|
||||
/** ShapeProps */
|
||||
ShapeProps: {
|
||||
/**
|
||||
* Kind
|
||||
* @enum {string}
|
||||
*/
|
||||
kind: "rect" | "ellipse" | "line" | "arrow";
|
||||
/**
|
||||
* Strokecolor
|
||||
* @default #000000
|
||||
*/
|
||||
strokeColor: string;
|
||||
/**
|
||||
* Fillcolor
|
||||
* @default transparent
|
||||
*/
|
||||
fillColor: string;
|
||||
/**
|
||||
* Strokewidth
|
||||
* @default 2
|
||||
*/
|
||||
strokeWidth: number;
|
||||
};
|
||||
/** SignatureAnnotation */
|
||||
SignatureAnnotation: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "signature";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
/** Props */
|
||||
props: components["schemas"]["SignatureDrawProps"] | components["schemas"]["SignatureTypeProps"];
|
||||
};
|
||||
/** SignatureDrawProps */
|
||||
SignatureDrawProps: {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
mode: "draw";
|
||||
/** Ref */
|
||||
ref: string;
|
||||
/**
|
||||
* Strokecolor
|
||||
* @default #000000
|
||||
*/
|
||||
strokeColor: string;
|
||||
};
|
||||
/** SignatureTypeProps */
|
||||
SignatureTypeProps: {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
mode: "type";
|
||||
/** Text */
|
||||
text: string;
|
||||
/** Fontfamily */
|
||||
fontFamily: string;
|
||||
/**
|
||||
* Color
|
||||
* @default #000000
|
||||
*/
|
||||
color: string;
|
||||
};
|
||||
/** TextAnnotation */
|
||||
TextAnnotation: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "text";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
props: components["schemas"]["TextProps"];
|
||||
};
|
||||
/** TextProps */
|
||||
TextProps: {
|
||||
/** Text */
|
||||
text: string;
|
||||
/**
|
||||
* Fontfamily
|
||||
* @default Liberation Sans
|
||||
*/
|
||||
fontFamily: string;
|
||||
/**
|
||||
* Fontsize
|
||||
* @default 14
|
||||
*/
|
||||
fontSize: number;
|
||||
/**
|
||||
* Color
|
||||
* @default #000000
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* Align
|
||||
* @default left
|
||||
* @enum {string}
|
||||
*/
|
||||
align: "left" | "center" | "right";
|
||||
/**
|
||||
* Bold
|
||||
* @default false
|
||||
*/
|
||||
bold: boolean;
|
||||
/**
|
||||
* Italic
|
||||
* @default false
|
||||
*/
|
||||
italic: boolean;
|
||||
/**
|
||||
* Lineheight
|
||||
* @default 1.2
|
||||
*/
|
||||
lineHeight: number;
|
||||
/** Highlightcolor */
|
||||
highlightColor?: string | null;
|
||||
/** Styles */
|
||||
styles?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
|
|
@ -834,20 +563,58 @@ export interface components {
|
|||
/** Context */
|
||||
ctx?: Record<string, never>;
|
||||
};
|
||||
/** VerifyCoordsRequest */
|
||||
VerifyCoordsRequest: {
|
||||
/** Document Id */
|
||||
document_id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/** X */
|
||||
x: number;
|
||||
/** Y */
|
||||
y: number;
|
||||
/** Width */
|
||||
width: number;
|
||||
/** Height */
|
||||
height: number;
|
||||
/** VersionCreateRequest */
|
||||
VersionCreateRequest: {
|
||||
/** Label */
|
||||
label?: string | null;
|
||||
/**
|
||||
* Kind
|
||||
* @default manual
|
||||
* @enum {string}
|
||||
*/
|
||||
kind: "manual" | "auto";
|
||||
};
|
||||
/** VersionDataResponse */
|
||||
VersionDataResponse: {
|
||||
/** Data */
|
||||
data: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
meta: components["schemas"]["VersionMeta"];
|
||||
};
|
||||
/** VersionListResponse */
|
||||
VersionListResponse: {
|
||||
/** Items */
|
||||
items: components["schemas"]["VersionMeta"][];
|
||||
};
|
||||
/** VersionMeta */
|
||||
VersionMeta: {
|
||||
/** Id */
|
||||
id: string;
|
||||
/** Documentid */
|
||||
documentId: string;
|
||||
/** Label */
|
||||
label: string | null;
|
||||
/**
|
||||
* Kind
|
||||
* @enum {string}
|
||||
*/
|
||||
kind: "manual" | "auto";
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/** Annotationcount */
|
||||
annotationCount: number;
|
||||
};
|
||||
/** VersionRestoreResponse */
|
||||
VersionRestoreResponse: {
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
|
|
@ -1052,7 +819,7 @@ export interface operations {
|
|||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
|
|
@ -1381,6 +1148,75 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
upload_asset_api_v1_documents__id__assets_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["Body_upload_asset_api_v1_documents__id__assets_post"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_asset_api_v1_documents__id__assets__ref__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
ref: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_annotations_api_v1_documents__document_id__annotations_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1447,11 +1283,13 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
health_check_api_v1_health_get: {
|
||||
list_versions_api_v1_documents__document_id__versions_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
|
|
@ -1462,23 +1300,161 @@ export interface operations {
|
|||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
"application/json": components["schemas"]["VersionListResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
verify_coords_api_v1_debug_verify_coords_post: {
|
||||
create_version_api_v1_documents__document_id__versions_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["VerifyCoordsRequest"];
|
||||
"application/json": components["schemas"]["VersionCreateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["VersionMeta"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_version_api_v1_documents__document_id__versions__version_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["VersionDataResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_version_api_v1_documents__document_id__versions__version_id__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
restore_version_api_v1_documents__document_id__versions__version_id__restore_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["VersionRestoreResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
export_document_api_v1_documents__document_id__export_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExportRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
|
|
@ -1502,4 +1478,26 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
health_check_api_v1_health_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,6 +272,46 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{id}/assets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Upload Asset
|
||||
* @description Upload a PNG, JPEG, GIF, or WebP annotation asset.
|
||||
*/
|
||||
post: operations["upload_asset_api_v1_documents__id__assets_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{id}/assets/{ref}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Asset
|
||||
* @description Serve an annotation asset to an authenticated session.
|
||||
*/
|
||||
get: operations["get_asset_api_v1_documents__id__assets__ref__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{document_id}/annotations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -290,6 +330,79 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{document_id}/versions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Versions */
|
||||
get: operations["list_versions_api_v1_documents__document_id__versions_get"];
|
||||
put?: never;
|
||||
/** Create Version */
|
||||
post: operations["create_version_api_v1_documents__document_id__versions_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{document_id}/versions/{version_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Version */
|
||||
get: operations["get_version_api_v1_documents__document_id__versions__version_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Delete Version */
|
||||
delete: operations["delete_version_api_v1_documents__document_id__versions__version_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{document_id}/versions/{version_id}/restore": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Restore Version */
|
||||
post: operations["restore_version_api_v1_documents__document_id__versions__version_id__restore_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/documents/{document_id}/export": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Export Document
|
||||
* @description Export the working layer or a selected version as a downloadable PDF.
|
||||
*/
|
||||
post: operations["export_document_api_v1_documents__document_id__export_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/health": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -310,28 +423,6 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/debug/verify-coords": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Verify Coords
|
||||
* @description Test endpoint for cross-engine coordinate verification.
|
||||
* Takes a canonical rect, draws it on the PDF using PyMuPDF,
|
||||
* and returns the flattened PDF.
|
||||
*/
|
||||
post: operations["verify_coords_api_v1_debug_verify_coords_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
|
|
@ -339,7 +430,9 @@ export interface components {
|
|||
/** AnnotationStateResponse */
|
||||
AnnotationStateResponse: {
|
||||
/** Data */
|
||||
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
|
||||
data: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
|
|
@ -349,7 +442,9 @@ export interface components {
|
|||
/** AnnotationStateUpdateRequest */
|
||||
AnnotationStateUpdateRequest: {
|
||||
/** Data */
|
||||
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
|
||||
data: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
/** Baseupdatedat */
|
||||
baseUpdatedAt?: string | null;
|
||||
};
|
||||
|
|
@ -368,6 +463,11 @@ export interface components {
|
|||
/** Loggedin */
|
||||
loggedIn: boolean;
|
||||
};
|
||||
/** Body_upload_asset_api_v1_documents__id__assets_post */
|
||||
Body_upload_asset_api_v1_documents__id__assets_post: {
|
||||
/** File */
|
||||
file: string;
|
||||
};
|
||||
/** Body_upload_document_api_v1_documents_post */
|
||||
Body_upload_document_api_v1_documents_post: {
|
||||
/** File */
|
||||
|
|
@ -425,396 +525,31 @@ export interface components {
|
|||
/** In Trash */
|
||||
in_trash?: boolean | null;
|
||||
};
|
||||
/** DrawAnnotation */
|
||||
DrawAnnotation: {
|
||||
/** ExportRequest */
|
||||
ExportRequest: {
|
||||
/** Versionid */
|
||||
versionId?: string | null;
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
* Flatten
|
||||
* @default true
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "draw";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
props: components["schemas"]["DrawProps"];
|
||||
};
|
||||
/** DrawProps */
|
||||
DrawProps: {
|
||||
/** Paths */
|
||||
paths: [
|
||||
number,
|
||||
number
|
||||
][];
|
||||
/**
|
||||
* Strokecolor
|
||||
* @default #000000
|
||||
*/
|
||||
strokeColor: string;
|
||||
/**
|
||||
* Strokewidth
|
||||
* @default 2
|
||||
*/
|
||||
strokeWidth: number;
|
||||
/**
|
||||
* Opacity
|
||||
* @default 1
|
||||
*/
|
||||
opacity: number;
|
||||
flatten: boolean;
|
||||
};
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
/** Detail */
|
||||
detail?: components["schemas"]["ValidationError"][];
|
||||
};
|
||||
/** HighlightAnnotation */
|
||||
HighlightAnnotation: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "highlight";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
props: components["schemas"]["HighlightProps"];
|
||||
};
|
||||
/** HighlightProps */
|
||||
HighlightProps: {
|
||||
/**
|
||||
* Color
|
||||
* @default #FFEB3B
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* Opacity
|
||||
* @default 0.3
|
||||
*/
|
||||
opacity: number;
|
||||
};
|
||||
/** ImageAnnotation */
|
||||
ImageAnnotation: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "image";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
props: components["schemas"]["ImageProps"];
|
||||
};
|
||||
/** ImageProps */
|
||||
ImageProps: {
|
||||
/** Ref */
|
||||
ref: string;
|
||||
/** Naturalwidth */
|
||||
naturalWidth: number;
|
||||
/** Naturalheight */
|
||||
naturalHeight: number;
|
||||
};
|
||||
/** LoginRequest */
|
||||
LoginRequest: {
|
||||
/** Password */
|
||||
password: string;
|
||||
};
|
||||
/** Rect */
|
||||
Rect: {
|
||||
/** X */
|
||||
x: number;
|
||||
/** Y */
|
||||
y: number;
|
||||
/** Width */
|
||||
width: number;
|
||||
/** Height */
|
||||
height: number;
|
||||
};
|
||||
/** SetupRequest */
|
||||
SetupRequest: {
|
||||
/** Password */
|
||||
password: string;
|
||||
};
|
||||
/** ShapeAnnotation */
|
||||
ShapeAnnotation: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "shape";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
props: components["schemas"]["ShapeProps"];
|
||||
};
|
||||
/** ShapeProps */
|
||||
ShapeProps: {
|
||||
/**
|
||||
* Kind
|
||||
* @enum {string}
|
||||
*/
|
||||
kind: "rect" | "ellipse" | "line" | "arrow";
|
||||
/**
|
||||
* Strokecolor
|
||||
* @default #000000
|
||||
*/
|
||||
strokeColor: string;
|
||||
/**
|
||||
* Fillcolor
|
||||
* @default transparent
|
||||
*/
|
||||
fillColor: string;
|
||||
/**
|
||||
* Strokewidth
|
||||
* @default 2
|
||||
*/
|
||||
strokeWidth: number;
|
||||
};
|
||||
/** SignatureAnnotation */
|
||||
SignatureAnnotation: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "signature";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
/** Props */
|
||||
props: components["schemas"]["SignatureDrawProps"] | components["schemas"]["SignatureTypeProps"];
|
||||
};
|
||||
/** SignatureDrawProps */
|
||||
SignatureDrawProps: {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
mode: "draw";
|
||||
/** Ref */
|
||||
ref: string;
|
||||
/**
|
||||
* Strokecolor
|
||||
* @default #000000
|
||||
*/
|
||||
strokeColor: string;
|
||||
};
|
||||
/** SignatureTypeProps */
|
||||
SignatureTypeProps: {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
mode: "type";
|
||||
/** Text */
|
||||
text: string;
|
||||
/** Fontfamily */
|
||||
fontFamily: string;
|
||||
/**
|
||||
* Color
|
||||
* @default #000000
|
||||
*/
|
||||
color: string;
|
||||
};
|
||||
/** TextAnnotation */
|
||||
TextAnnotation: {
|
||||
/**
|
||||
* Id
|
||||
* Format: uuid
|
||||
*/
|
||||
id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/**
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "text";
|
||||
rect: components["schemas"]["Rect"];
|
||||
/**
|
||||
* Rotation
|
||||
* @default 0
|
||||
*/
|
||||
rotation: number;
|
||||
/**
|
||||
* Z
|
||||
* @default 0
|
||||
*/
|
||||
z: number;
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
props: components["schemas"]["TextProps"];
|
||||
};
|
||||
/** TextProps */
|
||||
TextProps: {
|
||||
/** Text */
|
||||
text: string;
|
||||
/**
|
||||
* Fontfamily
|
||||
* @default Liberation Sans
|
||||
*/
|
||||
fontFamily: string;
|
||||
/**
|
||||
* Fontsize
|
||||
* @default 14
|
||||
*/
|
||||
fontSize: number;
|
||||
/**
|
||||
* Color
|
||||
* @default #000000
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* Align
|
||||
* @default left
|
||||
* @enum {string}
|
||||
*/
|
||||
align: "left" | "center" | "right";
|
||||
/**
|
||||
* Bold
|
||||
* @default false
|
||||
*/
|
||||
bold: boolean;
|
||||
/**
|
||||
* Italic
|
||||
* @default false
|
||||
*/
|
||||
italic: boolean;
|
||||
/**
|
||||
* Lineheight
|
||||
* @default 1.2
|
||||
*/
|
||||
lineHeight: number;
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
|
|
@ -828,20 +563,58 @@ export interface components {
|
|||
/** Context */
|
||||
ctx?: Record<string, never>;
|
||||
};
|
||||
/** VerifyCoordsRequest */
|
||||
VerifyCoordsRequest: {
|
||||
/** Document Id */
|
||||
document_id: string;
|
||||
/** Page */
|
||||
page: number;
|
||||
/** X */
|
||||
x: number;
|
||||
/** Y */
|
||||
y: number;
|
||||
/** Width */
|
||||
width: number;
|
||||
/** Height */
|
||||
height: number;
|
||||
/** VersionCreateRequest */
|
||||
VersionCreateRequest: {
|
||||
/** Label */
|
||||
label?: string | null;
|
||||
/**
|
||||
* Kind
|
||||
* @default manual
|
||||
* @enum {string}
|
||||
*/
|
||||
kind: "manual" | "auto";
|
||||
};
|
||||
/** VersionDataResponse */
|
||||
VersionDataResponse: {
|
||||
/** Data */
|
||||
data: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
meta: components["schemas"]["VersionMeta"];
|
||||
};
|
||||
/** VersionListResponse */
|
||||
VersionListResponse: {
|
||||
/** Items */
|
||||
items: components["schemas"]["VersionMeta"][];
|
||||
};
|
||||
/** VersionMeta */
|
||||
VersionMeta: {
|
||||
/** Id */
|
||||
id: string;
|
||||
/** Documentid */
|
||||
documentId: string;
|
||||
/** Label */
|
||||
label: string | null;
|
||||
/**
|
||||
* Kind
|
||||
* @enum {string}
|
||||
*/
|
||||
kind: "manual" | "auto";
|
||||
/**
|
||||
* Createdat
|
||||
* Format: date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/** Annotationcount */
|
||||
annotationCount: number;
|
||||
};
|
||||
/** VersionRestoreResponse */
|
||||
VersionRestoreResponse: {
|
||||
/**
|
||||
* Updatedat
|
||||
* Format: date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
|
|
@ -1046,7 +819,7 @@ export interface operations {
|
|||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
|
|
@ -1375,6 +1148,75 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
upload_asset_api_v1_documents__id__assets_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["Body_upload_asset_api_v1_documents__id__assets_post"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_asset_api_v1_documents__id__assets__ref__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
ref: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_annotations_api_v1_documents__document_id__annotations_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1441,11 +1283,13 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
health_check_api_v1_health_get: {
|
||||
list_versions_api_v1_documents__document_id__versions_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
|
|
@ -1456,23 +1300,161 @@ export interface operations {
|
|||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
"application/json": components["schemas"]["VersionListResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
verify_coords_api_v1_debug_verify_coords_post: {
|
||||
create_version_api_v1_documents__document_id__versions_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["VerifyCoordsRequest"];
|
||||
"application/json": components["schemas"]["VersionCreateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["VersionMeta"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_version_api_v1_documents__document_id__versions__version_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["VersionDataResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_version_api_v1_documents__document_id__versions__version_id__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
restore_version_api_v1_documents__document_id__versions__version_id__restore_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["VersionRestoreResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
export_document_api_v1_documents__document_id__export_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
document_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExportRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
|
|
@ -1496,4 +1478,26 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
health_check_api_v1_health_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue