Compare commits

..

No commits in common. "b0f25e3cd2a7f471ea72ee95da3c23804e9e9748" and "7ff5c8130dcabd11b688cb1d45dd151bfeb49264" have entirely different histories.

93 changed files with 4021 additions and 6452 deletions

View file

@ -1,20 +0,0 @@
.git
.forgejo
.env
.env.*
!.env.example
frontend/node_modules
frontend/dist
**/__pycache__
**/*.py[cod]
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/*.egg-info
*.sqlite
*.sqlite-shm
*.sqlite-wal
*.log

View file

@ -11,7 +11,7 @@
# --------------------------------------------
# Secret key for signing session cookies.
# Generate a strong random value: python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=replace-with-a-long-random-secret
SECRET_KEY=change-me-in-production
# --------------------------------------------
# Upload Limits
@ -40,13 +40,9 @@ COOKIE_SECURE=false
# --------------------------------------------
# Networking
# --------------------------------------------
# Port the PaperJet container publishes. Your outer reverse proxy targets this.
# Port the frontend container publishes. Your outer reverse proxy targets this.
HTTP_PORT=4982
# Optional image reference for a pulled Forgejo build.
# Leave unset for local builds; set this before docker compose pull in production.
# PAPERJET_IMAGE=git.elijahkuntz.com/your-user/paperjet:latest
# --------------------------------------------
# Debug
# --------------------------------------------

View file

@ -0,0 +1,25 @@
name: Automated Container Build
on:
push:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Log into Local Registry
run: |
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
- name: Build and Push Image
run: |
# Force the entire image path string to lowercase dynamically
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}:latest" | tr '[:upper:]' '[:lower:]')
docker build -t "$IMAGE_PATH" .
docker push "$IMAGE_PATH"

View file

@ -69,73 +69,3 @@ jobs:
- name: Test (vitest)
working-directory: frontend
run: npx vitest run
container:
name: Container (Docker)
runs-on: ubuntu-latest
needs: [backend, frontend]
steps:
- uses: actions/checkout@v4
- name: Validate Compose configuration
run: docker compose --env-file .env.example config --quiet
- name: Build container image
run: |
IMAGE_PATH=$(printf 'git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')
docker build \
-t paperjet:ci \
-t "$IMAGE_PATH:latest" \
-t "$IMAGE_PATH:${{ github.sha }}" \
.
- name: Run container smoke test
run: |
set -Eeuo pipefail
docker run \
--detach \
--name paperjet-ci \
--publish 18080:80 \
--env PAPERJET_SECRET_KEY=ci-only-secret \
--env PAPERJET_COOKIE_SECURE=false \
--env PAPERJET_DEBUG=false \
paperjet:ci
cleanup() {
docker rm --force paperjet-ci >/dev/null 2>&1 || true
}
trap cleanup EXIT
curl --fail --retry 30 --retry-delay 1 --retry-connrefused \
http://127.0.0.1:18080/
curl --fail --retry 30 --retry-delay 1 --retry-connrefused \
http://127.0.0.1:18080/api/v1/health
for attempt in $(seq 1 30); do
status=$(docker inspect --format '{{.State.Health.Status}}' paperjet-ci)
if [ "$status" = "healthy" ]; then
exit 0
fi
if [ "$status" = "unhealthy" ]; then
docker logs paperjet-ci
exit 1
fi
sleep 1
done
docker logs paperjet-ci
exit 1
- name: Log into Local Registry
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
- name: Push main image tags
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
IMAGE_PATH=$(printf 'git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')
docker push "$IMAGE_PATH:latest"
docker push "$IMAGE_PATH:${{ github.sha }}"

View file

@ -1,55 +0,0 @@
# syntax=docker/dockerfile:1
# Build the React/Vite application first so the runtime image contains only
# the compiled frontend and the Python/nginx runtime.
FROM node:22-alpine AS frontend-build
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --legacy-peer-deps
COPY frontend/ ./
RUN npm run build
# The runtime image contains both public-facing nginx and the loopback-only
# FastAPI/uvicorn process. This keeps deployment to one container while
# preserving the existing nginx -> API boundary.
FROM python:3.12-slim AS runtime
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
fontconfig \
fonts-liberation \
nginx \
&& rm -rf /var/lib/apt/lists/* \
&& fc-cache -fv \
&& rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf
WORKDIR /app
# Copy the source tree into /app before installing so the runtime can resolve
# the local package (including bundled signature fonts) from PYTHONPATH.
COPY backend/pyproject.toml ./
COPY backend/app ./app
RUN pip install --no-cache-dir .
COPY --from=frontend-build /frontend/dist /usr/share/nginx/html
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /usr/local/bin/paperjet-entrypoint
RUN chmod +x /usr/local/bin/paperjet-entrypoint \
&& nginx -t \
&& mkdir -p /data/pdfs /data/thumbnails /data/db
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1/api/v1/health', timeout=3)"]
ENTRYPOINT ["paperjet-entrypoint"]

View file

@ -1,10 +1,10 @@
# Self-Hosted PDF Editor — Implementation Plan
**Project codename:** `paperjet` (rename freely)
**Document version:** 1.2
**Document version:** 1.1
**Status:** Foundational specification — this document is the single source of truth. Every AI agent working on this project reads this file first, in full, before writing code.
> **v1.2 changes:** the runtime deployment is consolidated into one Docker image containing nginx, the compiled SPA, and FastAPI/uvicorn. The existing v1.1 decisions remain unchanged.
> **v1.1 changes:** resolved the four open questions — upload ceiling set to 200 MB; versioning reframed around accidental-loss recovery with a document-level **trash/restore** added as the primary safety net; cookie/proxy behavior pinned (direct HTTP for dev only, everything through the HTTPS proxy in production); signature capture specified as **draw + type-to-signature with live multi-font preview**.
---
@ -12,7 +12,7 @@
A self-hosted, single-user, browser-based PDF editor with a Sejda-style annotation workflow. The user adds text boxes, freehand drawing, signatures, images, highlights, and shapes onto a PDF, moves and resizes them freely, and exports a flattened PDF. The application persists work continuously (autosave), keeps a per-file version history, and supports undo/redo.
It runs on the user's own server via Docker Compose, is reachable over LAN and over WAN through an nginx reverse proxy, and is protected by a single password. It depends on **no external programs, no external containers, and no third-party APIs**. Libraries and packages bundled into the application image are permitted.
It runs on the user's own server via Docker Compose, is reachable over LAN and over WAN through an nginx reverse proxy, and is protected by a single password. It depends on **no external programs, no external containers, and no third-party APIs**. Libraries and packages bundled into the application's own containers are permitted.
### Non-goals (v1)
- Multi-user collaboration / real-time co-editing
@ -78,8 +78,8 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
| ORM | SQLAlchemy 2.x | With Alembic for migrations |
| Database | SQLite (WAL mode) | Single-user scale; embedded, no extra container |
| Password hashing | Argon2id (`argon2-cffi`) | |
| Web server | nginx | Serves the static React build and proxies /api to loopback uvicorn |
| ASGI server | uvicorn | Runs on loopback inside the same application image |
| Web server (frontend) | nginx | Serves the static React build; also the app-internal entry |
| ASGI server (backend) | uvicorn | Behind nginx |
| Orchestration | Docker Compose | |
| External programs / containers / APIs | **None** | Hard requirement |
@ -98,44 +98,43 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
┌─────────────▼─────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ paperjet image │ │
│ │ nginx :80 ── /api ──▶ uvicorn :8000 │ │
│ │ React SPA FastAPI + PyMuPDF │ │
│ └───────────────────┬──────────────────┘ │
│ │ │
│ ┌──────────▼────────┐ │
│ │ volumes │ │
│ │ pdf_storage/ │ │
│ │ thumbnails/ │ │
│ │ db/app.sqlite │ │
│ └───────────────────┘ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ frontend │ │ backend │ │
│ │ nginx │─────▶│ FastAPI │ │
│ │ serves SPA │ /api │ uvicorn │ │
│ │ proxies /api│ │ PyMuPDF │ │
│ └──────────────┘ │ SQLAlchemy │ │
│ └───────┬────────┘ │
│ │ │
│ ┌──────────▼────────┐ │
│ │ volumes │ │
│ │ pdf_storage/ │ │
│ │ thumbnails/ │ │
│ │ db/app.sqlite │ │
│ └───────────────────┘ │
└────────────────────────────────────────────┘
```
- The single `paperjet` container serves the SPA and reverse-proxies `/api/*` to its loopback-only FastAPI process. The browser sees a single origin — no CORS complexity, cookies "just work."
- The user's outer reverse proxy points at the published port of the `paperjet` container.
- The `frontend` nginx serves the SPA and reverse-proxies `/api/*` to the `backend`. This means the browser sees a single origin — no CORS complexity, cookies "just work."
- The user's outer reverse proxy points at the `frontend` container's published port.
---
## 5. Repository Structure
A single monorepo with one deployable runtime image. The Dockerfile uses a
Node build stage and a Python/nginx runtime stage.
A single monorepo. Two deployable images.
```
paperjet/
├── README.md
├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first
├── docker-compose.yml
├── Dockerfile
├── docker-entrypoint.sh
├── .dockerignore
├── .env.example
├── .forgejo/workflows/ci.yml # lint, tests, image build, and publish
├── .github/workflows/ci.yml # lint + typecheck + test on push
├── frontend/
│ ├── nginx.conf # SPA fallback + loopback /api proxy
│ ├── Dockerfile # multi-stage: build SPA, serve via nginx
│ ├── nginx.conf # SPA fallback + /api proxy
│ ├── package.json
│ ├── tsconfig.json # strict: true
│ ├── vite.config.ts
@ -164,6 +163,7 @@ paperjet/
│ └── types/ # generated from backend OpenAPI
├── backend/
│ ├── Dockerfile
│ ├── pyproject.toml
│ ├── alembic/ # migrations
│ └── app/
@ -342,7 +342,7 @@ In-document accidental edits during a single session are also covered by the in-
- The registry is a dict `{type: handler}`; adding a type adds a handler, nothing else.
- Missing handler → log + skip, never crash (§7.3).
- Export accepts an optional `versionId` to export a historical version instead of the working state.
- Fonts: bundle inside the application image (no external program) two font sets so server export matches the live editor exactly: (a) standard-metric fonts — the Liberation family for Helvetica/Times/Courier compatibility — for `text` annotations; and (b) a small curated set of **signature/script fonts** for typed signatures (§7.2, §12.5), the same faces offered in the editor's live preview. Embed used fonts into the output for portability.
- Fonts: bundle inside the backend image (no external program) two font sets so server export matches the live editor exactly: (a) standard-metric fonts — the Liberation family for Helvetica/Times/Courier compatibility — for `text` annotations; and (b) a small curated set of **signature/script fonts** for typed signatures (§7.2, §12.5), the same faces offered in the editor's live preview. Embed used fonts into the output for portability.
### 9.4 Assets (binary annotation payloads)
- `POST /documents/{id}/assets` (multipart) stores a signature PNG or placed image on the `pdf_storage` volume, returns `{ ref }`.
@ -499,29 +499,29 @@ Target the Sejda feel: bright, airy, light mode, generous whitespace, a restrain
## 13. Docker Compose & Deployment
```yaml
# docker-compose.yml
# docker-compose.yml (illustrative; agents finalize)
services:
paperjet:
image: ${PAPERJET_IMAGE:-paperjet:local}
build:
context: .
dockerfile: Dockerfile
backend:
build: ./backend
environment:
PAPERJET_SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting PaperJet}
PAPERJET_MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-200}
PAPERJET_TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
PAPERJET_AUTO_VERSION_RETENTION_DAYS: ${AUTO_VERSION_RETENTION_DAYS:-30}
PAPERJET_COOKIE_SECURE: ${COOKIE_SECURE:-false}
PAPERJET_DATABASE_PATH: /data/db/app.sqlite
PAPERJET_PDF_STORAGE_PATH: /data/pdfs
PAPERJET_THUMBNAILS_PATH: /data/thumbnails
PAPERJET_DEBUG: ${DEBUG:-false}
- SECRET_KEY=${SECRET_KEY}
- MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200}
- TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30}
- AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30}
- COOKIE_SECURE=${COOKIE_SECURE:-false} # false for direct-HTTP dev; set true in production (behind the HTTPS proxy)
volumes:
- pdf_storage:/data/pdfs
- thumbnails:/data/thumbnails
- db:/data/db
expose:
- "8000"
restart: unless-stopped
frontend:
build: ./frontend
depends_on: [backend]
ports:
- "${HTTP_PORT:-4982}:80"
- "${HTTP_PORT:-8080}:80" # user's outer reverse proxy targets this
restart: unless-stopped
volumes:
@ -530,10 +530,10 @@ volumes:
db:
```
- `frontend/nginx.conf` serves the SPA with history-API fallback and proxies `/api/` to `127.0.0.1:8000`, passing through cookies and the `X-Requested-With` header. Set `client_max_body_size` to match `MAX_UPLOAD_MB` (≥200 MB) so large PDFs aren't rejected at the proxy layer — and remind the user to raise the same limit in their **outer** reverse proxy, since that's the other place a large upload can be silently truncated.
- `frontend/nginx.conf` serves the SPA with history-API fallback and proxies `/api/` to `backend:8000`, passing through cookies and the `X-Requested-With` header. Set `client_max_body_size` to match `MAX_UPLOAD_MB` (≥200 MB) so large PDFs aren't rejected at the proxy layer — and remind the user to raise the same limit in their **outer** reverse proxy, since that's the other place a large upload can be silently truncated.
- A 200 MB ceiling comfortably covers 100200 page documents with embedded images; PDF.js page virtualization (§12.2) keeps the editor responsive on documents that large.
- The root Dockerfile builds the SPA with Node, installs production Python dependencies and bundled fonts, and copies both into one nginx/Python runtime image.
- docker-entrypoint.sh supervises nginx and loopback uvicorn so a failed child process stops the container cleanly.
- Backend Dockerfile installs Python deps and the bundled fonts; PyMuPDF comes from its wheel (no system packages, no external programs).
- Frontend Dockerfile is multi-stage: Node builds the SPA, the result is copied into an nginx image.
- All persistent state lives in named volumes mountable on the user's Unraid array. Document the volume → host-path mapping for backups.
---
@ -563,7 +563,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the
3. **One coordinate-transform module.** All space conversions go through `lib/coords.ts`. Never compute transforms inline in a component or duplicate the logic. Canonical space is always PDF points, top-left origin (§8).
4. **Store coordinates only in canonical space.** Never persist screen pixels.
5. **Types are generated, not hand-written.** After any backend schema change, regenerate the OpenAPI types for the frontend. Keep the TS annotation union and the Pydantic models in sync with `shared/annotation-schema.json`.
6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the application image are fine. If a task seems to need an external dependency, stop and flag it; do not add one.
6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the existing two images are fine. If a task seems to need an external dependency, stop and flag it; do not add one.
7. **Extend via registries.** New annotation types/tools register themselves; never scatter `switch(type)` logic across the codebase. Unknown types must round-trip without crashing.
8. **Security code is review-gated.** Do not modify auth, session, hashing, upload validation, or path handling without explicitly calling it out for review. Never log secrets or password material.
9. **Tests are required for the risk areas:** the coordinate module (round-trip + cross-engine), the export renderer per handler, auth flows, and upload validation. A feature touching these isn't done until its tests pass.
@ -580,7 +580,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the
Sequenced so each phase is independently testable and the riskiest correctness work (coordinates, export) is validated early.
**Phase 0 — Scaffolding**
Monorepo, the root Dockerfile and entrypoint, single-service Compose, `.env.example`, CI (lint/typecheck/test/container smoke), nginx config, empty FastAPI app with `/health`, SPA shell with routing. *Done when:* `docker compose up` serves a blank authenticated-shell app and `/api/v1/health` responds.
Monorepo, both Dockerfiles, compose, `.env.example`, CI (lint/typecheck/test), nginx config, empty FastAPI app with `/health`, SPA shell with routing. *Done when:* `docker compose up` serves a blank authenticated-shell app and `/api/v1/health` responds.
**Phase 1 — Auth & Library core**
First-run setup, login/logout, session cookie, rate limiting. Upload (button + drag-drop), document list, thumbnails, soft delete + **trash/restore** (single + bulk, empty trash, retention purge sweep), home page (recent + library + trash). *Done when:* a user can log in, upload, see, delete, and restore PDFs.
@ -616,4 +616,4 @@ All four open questions are now settled:
---
*End of plan v1.2. Amendments are made to this document first, then to code.*
*End of plan v1.1. Amendments are made to this document first, then to code.*

View file

@ -1,86 +0,0 @@
# PaperJet
PaperJet is a self-hosted, single-user PDF annotation editor. It keeps the uploaded PDF immutable, stores annotations separately, autosaves the working layer, and flattens supported annotations only when exporting.
## Run with Docker
1. Copy `.env.example` to `.env`.
2. Replace `SECRET_KEY` with a long random value. Compose refuses to start without it.
3. Build and start the single-container application:
```sh
docker compose up --build -d
```
The container serves the React application through nginx and proxies `/api`
requests to the loopback-only FastAPI process. Open `http://localhost:4982`
(or the value of `HTTP_PORT`). The first visit asks you to create the single
master password.
The `db`, `pdf_storage`, and `thumbnails` volumes contain application data.
Back them up before maintenance or upgrades. Put the frontend behind HTTPS
when exposing PaperJet beyond localhost and set `COOKIE_SECURE=true`.
### Run a Forgejo-published image
Set `PAPERJET_IMAGE` in `.env` to the desired registry tag, for example the
`latest` or commit-SHA tag published by Forgejo. Then pull and start without
building locally:
```sh
docker compose pull
docker compose up -d --no-build
```
The published image exposes only port 80. Your outer reverse proxy should
target the Compose port configured by `HTTP_PORT`.
## Development
Backend:
```sh
cd backend
pip install -e ".[dev]"
uvicorn app.main:app --reload
```
Frontend, in a second terminal:
```sh
cd frontend
npm ci
npm run dev
```
The Vite server proxies `/api` to `http://localhost:8000`.
## Validation
```sh
# container
docker compose --env-file .env.example config --quiet
docker compose up --build -d
curl http://localhost:4982/api/v1/health
docker compose ps
# frontend
cd frontend
npm test -- --run
npm run typecheck
npm run lint
npm run build
# backend
cd ../backend
pytest -q
mypy app/
ruff check .
```
Stop the local stack with `docker compose down`. Do not use `-v` unless you
intend to remove the local application volumes.
The editor uses PDF.js for display and PyMuPDF for export. Stored annotation
coordinates are PDF points with a top-left origin relative to the unrotated
CropBox; this is the contract shared by the editor and export renderer.

25
backend/Dockerfile Normal file
View file

@ -0,0 +1,25 @@
FROM python:3.12-slim AS backend
# System dependencies: fonts for PDF export + fontconfig
RUN apt-get update && \
apt-get install -y --no-install-recommends \
fonts-liberation \
fontconfig && \
rm -rf /var/lib/apt/lists/* && \
fc-cache -fv
WORKDIR /app
# Install Python dependencies
COPY pyproject.toml ./
RUN pip install --no-cache-dir -e ".[dev]"
# Copy application code
COPY . .
# Create data directories (will be overridden by volume mounts)
RUN mkdir -p /data/pdfs /data/thumbnails /data/db
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View file

@ -2,11 +2,11 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.db import Base
from app.models import * # noqa: F403 — ensure all models are imported
from app.models import * # noqa: F401, F403 — ensure all models are imported
config = context.config
if config.config_file_name is not None:

View file

@ -2,14 +2,9 @@
from fastapi import APIRouter
from app.api.v1.annotations import router as annotations_router
from app.api.v1.assets import router as assets_router
from app.api.v1.auth import router as auth_router
from app.api.v1.documents import router as documents_router
from app.api.v1.export import router as export_router
from app.api.v1.health import router as health_router
from app.api.v1.versions import router as versions_router
from app.config import settings
router = APIRouter(prefix="/api/v1")
@ -20,18 +15,17 @@ router.include_router(auth_router)
router.include_router(documents_router)
# Assets
from app.api.v1.assets import router as assets_router
router.include_router(assets_router)
# Annotations
from app.api.v1.annotations import router as annotations_router
router.include_router(annotations_router)
# Versions and export
router.include_router(versions_router)
router.include_router(export_router)
# Health (unauthenticated)
router.include_router(health_router)
from app.config import settings
if settings.DEBUG:
from app.api.v1.debug import router as debug_router
router.include_router(debug_router)

View file

@ -1,95 +1,91 @@
"""Opaque working-state annotation endpoints."""
import json
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from datetime import datetime, timezone
import json
from app.auth.dependencies import get_current_user, verify_csrf
from app.db import get_db
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.schemas.annotations import (
AnnotationPayload,
AnnotationStateResponse,
AnnotationStateUpdateRequest,
AnnotationStateUpdateResponse,
)
router = APIRouter(prefix="/documents", tags=["annotations"])
from app.models.annotation_state import AnnotationState
from app.auth.dependencies import get_current_user, verify_csrf
from app.schemas.annotations import AnnotationStateResponse, AnnotationStateUpdateRequest, AnnotationStateUpdateResponse
router = APIRouter(prefix="/documents", tags=["Annotations"])
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
return datetime.now(timezone.utc).isoformat()
def _utc(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def _document_or_404(document_id: str, db: Session) -> Document:
document = db.scalar(
select(Document).where(Document.id == document_id, Document.deleted_at.is_(None))
)
if not document:
@router.get("/{document_id}/annotations", response_model=AnnotationStateResponse)
async def get_annotations(
document_id: str,
db: Session = Depends(get_db),
user_id: Any = Depends(get_current_user)
):
doc = db.scalar(select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)))
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return document
def _read_data(state: AnnotationState | None) -> list[AnnotationPayload]:
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
if not state:
return []
return AnnotationStateResponse(data=[], updatedAt=datetime.now(timezone.utc))
try:
data = json.loads(state.data)
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
data = []
return AnnotationStateResponse(
data=data,
updatedAt=datetime.fromisoformat(state.updated_at)
)
@router.get("/{document_id}/annotations", response_model=AnnotationStateResponse)
def get_annotations(
document_id: str,
db: Session = Depends(get_db),
_user_id: int = Depends(get_current_user),
) -> AnnotationStateResponse:
_document_or_404(document_id, db)
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
updated_at = datetime.now(UTC) if not state else _utc(datetime.fromisoformat(state.updated_at))
return AnnotationStateResponse(data=_read_data(state), updatedAt=updated_at)
@router.put(
"/{document_id}/annotations",
response_model=AnnotationStateUpdateResponse,
dependencies=[Depends(verify_csrf)],
)
def update_annotations(
@router.put("/{document_id}/annotations", response_model=AnnotationStateUpdateResponse, dependencies=[Depends(verify_csrf)])
async def update_annotations(
document_id: str,
request: AnnotationStateUpdateRequest,
db: Session = Depends(get_db),
_user_id: int = Depends(get_current_user),
) -> AnnotationStateUpdateResponse:
document = _document_or_404(document_id, db)
user_id: Any = Depends(get_current_user)
):
doc = db.scalar(select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)))
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
# Check for stale tab
if state and request.baseUpdatedAt:
current_updated_at = _utc(datetime.fromisoformat(state.updated_at))
request_updated_at = _utc(request.baseUpdatedAt)
current_updated_at = datetime.fromisoformat(state.updated_at).replace(tzinfo=timezone.utc)
request_updated_at = request.baseUpdatedAt.replace(tzinfo=timezone.utc)
# Allow a small grace period for timezone/parsing discrepancies
if abs((current_updated_at - request_updated_at).total_seconds()) > 1.0:
raise HTTPException(
status_code=409,
detail=f"Conflict: annotations have been modified since {request.baseUpdatedAt}",
status_code=409,
detail=f"Conflict: annotations have been modified since {request.baseUpdatedAt}"
)
now = _now_iso()
data_json = json.dumps(request.data, separators=(",", ":"))
if state:
import json
# serialize request.data list of models to json string
data_json = json.dumps([a.model_dump(mode="json", by_alias=True) for a in request.data])
if not state:
state = AnnotationState(
document_id=document_id,
data=data_json,
updated_at=now
)
db.add(state)
else:
state.data = data_json
state.updated_at = now
else:
db.add(AnnotationState(document_id=document_id, data=data_json, updated_at=now))
document.updated_at = now
# Bump document updated_at
doc.updated_at = now
db.commit()
return AnnotationStateUpdateResponse(updatedAt=datetime.fromisoformat(now))

View file

@ -1,101 +1,58 @@
"""Authenticated binary assets used by annotations."""
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
import uuid
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, verify_csrf
from app.config import settings
from app.db import get_db
from app.models.document import Document
from app.auth.dependencies import get_current_user
from app.config import settings
router = APIRouter(prefix="/documents", tags=["Assets"])
_ASSET_CHUNK_SIZE = 1024 * 1024
_MAX_ASSET_BYTES = 25 * 1024 * 1024
def get_asset_path(document_id: str, ref: str) -> Path:
# Ensure directory exists
dir_path = settings.PDF_STORAGE_PATH / "assets" / document_id
dir_path.mkdir(parents=True, exist_ok=True)
return dir_path / ref
def get_asset_path(document_id: str, ref: str, *, create: bool = False) -> Path:
"""Return a safe asset path; only writes are allowed to create directories."""
try:
asset_ref = str(uuid.UUID(ref))
except ValueError as err:
raise HTTPException(status_code=404, detail="Asset not found") from err
directory = settings.PDF_STORAGE_PATH / "assets" / document_id
if create:
directory.mkdir(parents=True, exist_ok=True)
return directory / asset_ref
def _is_supported_image(filepath: Path) -> bool:
with filepath.open("rb") as file:
header = file.read(12)
return (
header.startswith(b"\x89PNG\r\n\x1a\n")
or header.startswith(b"\xff\xd8\xff")
or header.startswith((b"GIF87a", b"GIF89a"))
or (header.startswith(b"RIFF") and header[8:12] == b"WEBP")
)
@router.post("/{id}/assets", dependencies=[Depends(verify_csrf)])
@router.post("/{id}/assets")
async def upload_asset(
id: str,
file: UploadFile = File(...),
user_id: int = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict[str, str]:
"""Upload a PNG, JPEG, GIF, or WebP annotation asset."""
document = db.scalar(select(Document).where(Document.id == id))
if not document:
raise HTTPException(status_code=404, detail="Document not found")
db: Session = Depends(get_db)
):
"""Upload a binary asset (like an image or signature) for a document."""
# In a real app we'd verify the user owns the document here
# For this single-user app, we trust the ID
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Only image assets are supported")
ref = str(uuid.uuid4())
filepath = get_asset_path(id, ref, create=True)
total_bytes = 0
filepath = get_asset_path(id, ref)
try:
with filepath.open("wb") as destination:
while chunk := file.file.read(_ASSET_CHUNK_SIZE):
total_bytes += len(chunk)
if total_bytes > _MAX_ASSET_BYTES:
raise HTTPException(status_code=413, detail="Image asset is too large")
destination.write(chunk)
except HTTPException:
filepath.unlink(missing_ok=True)
raise
except Exception as err:
filepath.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="Failed to save asset") from err
if not _is_supported_image(filepath):
filepath.unlink(missing_ok=True)
raise HTTPException(
status_code=400,
detail="Only PNG, JPEG, GIF, and WebP images are supported",
)
return {"ref": ref, "url": f"/api/v1/documents/{id}/assets/{ref}"}
with open(filepath, "wb") as f:
shutil.copyfileobj(file.file, f)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to save asset: {e}")
return {
"ref": ref,
"url": f"/api/v1/documents/{id}/assets/{ref}"
}
@router.get("/{id}/assets/{ref}")
async def get_asset(
id: str,
ref: str,
db: Session = Depends(get_db),
user_id: int = Depends(get_current_user),
) -> FileResponse:
"""Serve an annotation asset to an authenticated session."""
document = db.scalar(select(Document).where(Document.id == id))
if not document:
raise HTTPException(status_code=404, detail="Document not found")
):
"""Serve a binary asset."""
filepath = get_asset_path(id, ref)
if not filepath.exists() or not filepath.is_file():
raise HTTPException(status_code=404, detail="Asset not found")
return FileResponse(filepath)

View file

@ -31,37 +31,37 @@ def get_auth_status(request: Request, db: Session = Depends(get_db)) -> AuthStat
app_settings = Settings()
db.add(app_settings)
db.commit()
setup_required = app_settings.password_hash is None
token = request.cookies.get(settings.COOKIE_NAME)
logged_in = False
if token:
user_id = verify_session(token)
if user_id is not None:
logged_in = True
return AuthStatusResponse(setupRequired=setup_required, loggedIn=logged_in)
@router.post("/setup", dependencies=[Depends(verify_csrf)])
def setup_password(
data: SetupRequest,
response: Response,
data: SetupRequest,
response: Response,
db: Session = Depends(get_db)
) -> dict[str, str]:
"""First-run setup."""
app_settings = db.query(Settings).first()
if app_settings and app_settings.password_hash:
raise HTTPException(status_code=409, detail="Password already set")
if not app_settings:
app_settings = Settings()
db.add(app_settings)
app_settings.password_hash = hash_password(data.password)
db.commit()
# Log them in automatically
create_session(response)
return {"status": "ok"}
@ -77,16 +77,16 @@ def login(
"""Validate password and issue session cookie."""
ip = get_client_ip(request)
check_rate_limit(ip)
app_settings = db.query(Settings).first()
if not app_settings or not app_settings.password_hash:
record_failed_attempt(ip)
raise HTTPException(status_code=401, detail="Invalid credentials")
if not verify_password(app_settings.password_hash, data.password):
record_failed_attempt(ip)
raise HTTPException(status_code=401, detail="Invalid credentials")
clear_attempts(ip)
create_session(response)
return {"status": "ok"}
@ -108,17 +108,17 @@ def change_password(
"""Change an existing password."""
ip = get_client_ip(request)
check_rate_limit(ip)
app_settings = db.query(Settings).first()
if not app_settings or not app_settings.password_hash:
raise HTTPException(status_code=400, detail="Setup required first")
if not verify_password(app_settings.password_hash, data.current_password):
record_failed_attempt(ip)
raise HTTPException(status_code=401, detail="Invalid current password")
clear_attempts(ip)
app_settings.password_hash = hash_password(data.new_password)
db.commit()
return {"status": "ok"}

View file

@ -28,28 +28,28 @@ async def verify_coords(req: VerifyCoordsRequest):
try:
doc = pymupdf.open(filepath)
except Exception as error:
raise HTTPException(status_code=500, detail=str(error)) from error
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if req.page < 0 or req.page >= len(doc):
doc.close()
raise HTTPException(status_code=400, detail="Invalid page number")
page = doc[req.page]
# Canonical space is defined as relative to the unrotated CropBox.
# PyMuPDF naturally draws relative to the current rotation.
# By temporarily setting rotation to 0, we can draw directly using canonical coordinates.
original_rotation = page.rotation
if original_rotation != 0:
page.set_rotation(0)
rect = pymupdf.Rect(req.x, req.y, req.x + req.width, req.y + req.height)
page.draw_rect(rect, color=(1, 0, 0), width=2, fill=(1, 0, 0), fill_opacity=0.3)
if original_rotation != 0:
page.set_rotation(original_rotation)
pdf_bytes = doc.write()
doc.close()

View file

@ -1,31 +1,28 @@
"""Document management endpoints."""
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import FileResponse
from sqlalchemy import desc
from sqlalchemy.orm import Session
from sqlalchemy import desc
from datetime import datetime, timezone
from app.auth.dependencies import get_current_user, verify_csrf
from app.config import settings
from app.db import get_db
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.models.annotation_state import AnnotationState
from app.models.version import Version
from app.schemas.document import (
BulkDeleteRequest,
BulkRestoreRequest,
DocumentListResponse,
DocumentMeta,
DocumentUpdateRequest,
DocumentListResponse, DocumentMeta, BulkDeleteRequest,
BulkRestoreRequest, DocumentUpdateRequest
)
from app.services.assets import delete_asset_directory
from app.services.storage import delete_pdf_file, safe_filename, save_upload_file
from app.services.thumbnails import delete_thumbnail, generate_thumbnail
from app.services.storage import save_upload_file, delete_pdf_file
from app.services.thumbnails import generate_thumbnail, delete_thumbnail
from app.config import settings
router = APIRouter(
prefix="/documents", tags=["documents"], dependencies=[Depends(get_current_user)]
prefix="/documents",
tags=["documents"],
dependencies=[Depends(get_current_user)]
)
@ -34,135 +31,109 @@ def list_documents(
query: str = "",
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
) -> DocumentListResponse:
db: Session = Depends(get_db)
):
"""List non-trashed documents."""
q = db.query(Document).filter(Document.deleted_at.is_(None))
if query:
q = q.filter(Document.title.ilike(f"%{query}%"))
total = q.count()
items = q.order_by(desc(Document.updated_at)).offset(skip).limit(limit).all()
return DocumentListResponse(
items=[DocumentMeta.model_validate(item) for item in items], total=total
)
return DocumentListResponse(items=items, total=total)
@router.get("/trash", response_model=DocumentListResponse)
def list_trash(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
db: Session = Depends(get_db),
) -> DocumentListResponse:
db: Session = Depends(get_db)
):
"""List trashed documents."""
q = db.query(Document).filter(Document.deleted_at.is_not(None))
total = q.count()
items = q.order_by(desc(Document.deleted_at)).offset(skip).limit(limit).all()
return DocumentListResponse(
items=[DocumentMeta.model_validate(item) for item in items], total=total
)
return DocumentListResponse(items=items, total=total)
@router.post(
"",
dependencies=[Depends(verify_csrf)],
response_model=DocumentMeta,
status_code=status.HTTP_201_CREATED,
)
@router.post("", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
async def upload_document(
file: UploadFile = File(...), db: Session = Depends(get_db)
) -> DocumentMeta:
file: UploadFile = File(...),
db: Session = Depends(get_db)
):
"""Upload a new PDF document."""
# Note: Starlette/FastAPI loads file into memory/spooled file.
# To truly enforce max size before reading we rely on Nginx and logic in storage.py.
# But checking size if available:
if file.size and file.size > settings.MAX_UPLOAD_MB * 1024 * 1024:
raise HTTPException(status_code=413, detail="File too large")
file_id, size_bytes, page_count = await save_upload_file(file)
# Generate thumbnail synchronously
thumbnail_generated = generate_thumbnail(file_id)
now = datetime.now(UTC).isoformat()
filename = safe_filename(file.filename)
generate_thumbnail(file_id)
now = datetime.now(timezone.utc).isoformat()
doc = Document(
id=file_id,
title=filename,
original_filename=filename,
title=file.filename or "Untitled",
original_filename=file.filename or "Untitled.pdf",
file_path=str(settings.PDF_STORAGE_PATH / f"{file_id}.pdf"),
thumbnail_path=(
str(settings.THUMBNAILS_PATH / f"{file_id}.png") if thumbnail_generated else None
),
page_count=page_count,
size_bytes=size_bytes,
created_at=now,
updated_at=now,
updated_at=now
)
try:
db.add(doc)
db.commit()
except Exception:
delete_pdf_file(file_id)
delete_thumbnail(file_id)
delete_asset_directory(file_id)
raise
db.add(doc)
db.commit()
db.refresh(doc)
return DocumentMeta.model_validate(doc)
return doc
@router.get("/{id}", response_model=DocumentMeta)
def get_document(id: str, db: Session = Depends(get_db)) -> DocumentMeta:
doc = db.query(Document).filter(Document.id == id, Document.deleted_at.is_(None)).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentMeta.model_validate(doc)
@router.patch("/{id}", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
def update_document(
id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db)
) -> DocumentMeta:
def get_document(id: str, db: Session = Depends(get_db)):
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return doc
@router.patch("/{id}", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
def update_document(id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db)):
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if update.title is not None:
title = update.title.strip()
if not title:
raise HTTPException(status_code=422, detail="Title cannot be empty")
doc.title = title
doc.title = update.title
if update.in_trash is not None:
if update.in_trash:
doc.deleted_at = datetime.now(UTC).isoformat()
doc.deleted_at = datetime.now(timezone.utc).isoformat()
else:
doc.deleted_at = None
doc.updated_at = datetime.now(UTC).isoformat()
doc.updated_at = datetime.now(timezone.utc).isoformat()
db.commit()
db.refresh(doc)
return DocumentMeta.model_validate(doc)
return doc
@router.delete("/{id}", dependencies=[Depends(verify_csrf)])
def delete_document(
id: str, permanent: bool = False, db: Session = Depends(get_db)
) -> dict[str, str]:
def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_db)):
"""Soft delete by default. Hard delete if permanent=True AND already in trash."""
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if permanent:
if doc.deleted_at is None:
raise HTTPException(status_code=400, detail="Must be in trash to delete permanently")
# Hard delete
delete_pdf_file(doc.id)
delete_thumbnail(doc.id)
delete_asset_directory(doc.id)
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
db.query(Version).filter(Version.document_id == doc.id).delete()
db.delete(doc)
@ -170,57 +141,52 @@ def delete_document(
return {"status": "deleted"}
else:
# Soft delete
doc.deleted_at = datetime.now(UTC).isoformat()
doc.updated_at = doc.deleted_at
doc.deleted_at = datetime.now(timezone.utc).isoformat()
db.commit()
return {"status": "trashed"}
@router.post("/{id}/restore", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
def restore_document(id: str, db: Session = Depends(get_db)) -> DocumentMeta:
def restore_document(id: str, db: Session = Depends(get_db)):
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
doc.deleted_at = None
doc.updated_at = datetime.now(UTC).isoformat()
doc.updated_at = datetime.now(timezone.utc).isoformat()
db.commit()
db.refresh(doc)
return DocumentMeta.model_validate(doc)
return doc
@router.post("/bulk-delete", dependencies=[Depends(verify_csrf)])
def bulk_delete(data: BulkDeleteRequest, db: Session = Depends(get_db)) -> dict[str, int | str]:
now = datetime.now(UTC).isoformat()
count = (
db.query(Document)
.filter(Document.id.in_(data.ids), Document.deleted_at.is_(None))
.update({Document.deleted_at: now}, synchronize_session=False)
)
def bulk_delete(data: BulkDeleteRequest, db: Session = Depends(get_db)):
now = datetime.now(timezone.utc).isoformat()
db.query(Document).filter(Document.id.in_(data.ids)).update({
Document.deleted_at: now
}, synchronize_session=False)
db.commit()
return {"status": "ok", "count": count}
return {"status": "ok", "count": len(data.ids)}
@router.post("/bulk-restore", dependencies=[Depends(verify_csrf)])
def bulk_restore(data: BulkRestoreRequest, db: Session = Depends(get_db)) -> dict[str, int | str]:
now = datetime.now(UTC).isoformat()
count = (
db.query(Document)
.filter(Document.id.in_(data.ids), Document.deleted_at.is_not(None))
.update({Document.deleted_at: None, Document.updated_at: now}, synchronize_session=False)
)
def bulk_restore(data: BulkRestoreRequest, db: Session = Depends(get_db)):
now = datetime.now(timezone.utc).isoformat()
db.query(Document).filter(Document.id.in_(data.ids)).update({
Document.deleted_at: None,
Document.updated_at: now
}, synchronize_session=False)
db.commit()
return {"status": "ok", "count": count}
return {"status": "ok", "count": len(data.ids)}
@router.post("/trash/empty", dependencies=[Depends(verify_csrf)])
def empty_trash(db: Session = Depends(get_db)) -> dict[str, int | str]:
def empty_trash(db: Session = Depends(get_db)):
docs = db.query(Document).filter(Document.deleted_at.is_not(None)).all()
count = 0
for doc in docs:
delete_pdf_file(doc.id)
delete_thumbnail(doc.id)
delete_asset_directory(doc.id)
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
db.query(Version).filter(Version.document_id == doc.id).delete()
db.delete(doc)
@ -230,26 +196,26 @@ def empty_trash(db: Session = Depends(get_db)) -> dict[str, int | str]:
@router.get("/{id}/file")
def get_document_file(id: str, db: Session = Depends(get_db)) -> FileResponse:
def get_document_file(id: str, db: Session = Depends(get_db)):
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
path = settings.PDF_STORAGE_PATH / f"{doc.id}.pdf"
if not path.exists():
raise HTTPException(status_code=404, detail="File missing")
return FileResponse(path, media_type="application/pdf", filename=doc.filename)
@router.get("/{id}/thumbnail")
def get_document_thumbnail(id: str, db: Session = Depends(get_db)) -> FileResponse:
def get_document_thumbnail(id: str, db: Session = Depends(get_db)):
doc = db.query(Document).filter(Document.id == id).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
path = settings.THUMBNAILS_PATH / f"{doc.id}.png"
if not path.exists():
raise HTTPException(status_code=404, detail="Thumbnail missing")
return FileResponse(path, media_type="image/png")

View file

@ -1,94 +0,0 @@
"""Flattened PDF export endpoint."""
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, verify_csrf
from app.config import settings
from app.db import get_db
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.models.version import Version
from app.schemas.export import ExportRequest
from app.services.export.renderer import export_annotations
from app.services.storage import safe_filename
router = APIRouter(
prefix="/documents",
tags=["export"],
dependencies=[Depends(get_current_user)],
)
def _document_or_404(document_id: str, db: Session) -> Document:
document = db.scalar(
select(Document).where(Document.id == document_id, Document.deleted_at.is_(None))
)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
return document
def _state_data(document_id: str, db: Session) -> list[dict[str, object]]:
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
if not state:
return []
try:
data = json.loads(state.data)
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
@router.post("/{document_id}/export", dependencies=[Depends(verify_csrf)])
def export_document(
document_id: str,
request: ExportRequest,
db: Session = Depends(get_db),
) -> Response:
"""Export the working layer or a selected version as a downloadable PDF."""
document = _document_or_404(document_id, db)
pdf_path = settings.PDF_STORAGE_PATH / f"{document.id}.pdf"
if not pdf_path.is_file():
raise HTTPException(status_code=404, detail="Original PDF is missing")
if not request.flatten:
content = pdf_path.read_bytes()
elif request.versionId:
version = db.scalar(
select(Version).where(
Version.id == request.versionId,
Version.document_id == document_id,
)
)
if not version:
raise HTTPException(status_code=404, detail="Version not found")
try:
version_data = json.loads(version.data)
except json.JSONDecodeError:
version_data = []
content = export_annotations(
pdf_path,
version_data if isinstance(version_data, list) else [],
settings.PDF_STORAGE_PATH / "assets" / document.id,
)
else:
content = export_annotations(
pdf_path,
_state_data(document_id, db),
settings.PDF_STORAGE_PATH / "assets" / document.id,
)
filename = safe_filename(document.original_filename)
if not filename.lower().endswith(".pdf"):
filename = f"{filename}.pdf"
filename = filename.replace('"', "'").replace("\r", "").replace("\n", "")
return Response(
content=content,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

View file

@ -1,203 +0,0 @@
"""Annotation checkpoint and restore endpoints."""
import json
from datetime import UTC, datetime
from typing import Literal, cast
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, verify_csrf
from app.db import get_db
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.models.version import Version
from app.schemas.annotations import AnnotationPayload
from app.schemas.versions import (
VersionCreateRequest,
VersionDataResponse,
VersionListResponse,
VersionMeta,
VersionRestoreResponse,
)
router = APIRouter(
prefix="/documents",
tags=["versions"],
dependencies=[Depends(get_current_user)],
)
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _document_or_404(document_id: str, db: Session) -> Document:
document = db.scalar(
select(Document).where(Document.id == document_id, Document.deleted_at.is_(None))
)
if not document:
raise HTTPException(status_code=404, detail="Document not found")
return document
def _read_state(document_id: str, db: Session) -> list[AnnotationPayload]:
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
if not state:
return []
try:
data = json.loads(state.data)
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
def _version_meta(version: Version) -> VersionMeta:
try:
data = json.loads(version.data)
except json.JSONDecodeError:
data = []
return VersionMeta(
id=version.id,
documentId=version.document_id,
label=version.label,
kind=cast(
Literal["manual", "auto"],
version.kind if version.kind in {"manual", "auto"} else "auto",
),
createdAt=datetime.fromisoformat(version.created_at),
annotationCount=len(data) if isinstance(data, list) else 0,
)
def _create_snapshot(
document_id: str,
db: Session,
*,
label: str | None,
kind: str,
) -> Version:
version = Version(
document_id=document_id,
label=label,
kind=kind,
data=json.dumps(_read_state(document_id, db), separators=(",", ":")),
)
db.add(version)
db.flush()
return version
@router.get("/{document_id}/versions", response_model=VersionListResponse)
def list_versions(document_id: str, db: Session = Depends(get_db)) -> VersionListResponse:
_document_or_404(document_id, db)
versions = db.scalars(
select(Version).where(Version.document_id == document_id).order_by(desc(Version.created_at))
).all()
return VersionListResponse(items=[_version_meta(version) for version in versions])
@router.post(
"/{document_id}/versions",
response_model=VersionMeta,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(verify_csrf)],
)
def create_version(
document_id: str,
request: VersionCreateRequest,
db: Session = Depends(get_db),
) -> VersionMeta:
_document_or_404(document_id, db)
version = _create_snapshot(
document_id,
db,
label=request.label.strip() if request.label and request.label.strip() else None,
kind=request.kind,
)
db.commit()
return _version_meta(version)
@router.get("/{document_id}/versions/{version_id}", response_model=VersionDataResponse)
def get_version(
document_id: str,
version_id: str,
db: Session = Depends(get_db),
) -> VersionDataResponse:
_document_or_404(document_id, db)
version = db.scalar(
select(Version).where(Version.id == version_id, Version.document_id == document_id)
)
if not version:
raise HTTPException(status_code=404, detail="Version not found")
try:
data = json.loads(version.data)
except json.JSONDecodeError:
data = []
return VersionDataResponse(
data=data if isinstance(data, list) else [],
meta=_version_meta(version),
)
@router.post(
"/{document_id}/versions/{version_id}/restore",
response_model=VersionRestoreResponse,
dependencies=[Depends(verify_csrf)],
)
def restore_version(
document_id: str,
version_id: str,
db: Session = Depends(get_db),
) -> VersionRestoreResponse:
document = _document_or_404(document_id, db)
target = db.scalar(
select(Version).where(Version.id == version_id, Version.document_id == document_id)
)
if not target:
raise HTTPException(status_code=404, detail="Version not found")
# Restoring is destructive to the current working layer, so make it undoable.
_create_snapshot(document_id, db, label="Before restore", kind="auto")
try:
restored_data = json.loads(target.data)
except json.JSONDecodeError:
restored_data = []
if not isinstance(restored_data, list):
restored_data = []
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
now = _now_iso()
if state:
state.data = json.dumps(restored_data, separators=(",", ":"))
state.updated_at = now
else:
db.add(
AnnotationState(
document_id=document_id,
data=json.dumps(restored_data, separators=(",", ":")),
updated_at=now,
)
)
document.updated_at = now
db.commit()
return VersionRestoreResponse(updatedAt=datetime.fromisoformat(now))
@router.delete(
"/{document_id}/versions/{version_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(verify_csrf)],
)
def delete_version(document_id: str, version_id: str, db: Session = Depends(get_db)) -> Response:
_document_or_404(document_id, db)
version = db.scalar(
select(Version).where(Version.id == version_id, Version.document_id == document_id)
)
if not version:
raise HTTPException(status_code=404, detail="Version not found")
db.delete(version)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -1,93 +0,0 @@
Copyright 2015 The Great Vibes Pro Project Authors (https://github.com/googlefonts/great-vibes)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

View file

@ -1,11 +1,10 @@
"""FastAPI dependencies for authentication and security."""
from fastapi import HTTPException, Request
from fastapi import Request, HTTPException
from app.auth.session import verify_session
from app.config import settings
def get_current_user(request: Request) -> int:
"""
Dependency that extracts and validates the session cookie.
@ -15,11 +14,11 @@ def get_current_user(request: Request) -> int:
token = request.cookies.get(settings.COOKIE_NAME)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = verify_session(token)
if not user_id:
raise HTTPException(status_code=401, detail="Session expired or invalid")
return user_id
@ -28,11 +27,9 @@ def verify_csrf(request: Request) -> None:
Dependency to mitigate CSRF for cookie-based auth.
Requires X-Requested-With header on all mutating requests.
"""
if (
request.method in ["POST", "PUT", "PATCH", "DELETE"]
and request.headers.get("X-Requested-With") != "XMLHttpRequest"
):
raise HTTPException(
status_code=403,
detail="CSRF check failed: missing X-Requested-With header",
)
if request.method in ["POST", "PUT", "PATCH", "DELETE"]:
if request.headers.get("X-Requested-With") != "XMLHttpRequest":
raise HTTPException(
status_code=403,
detail="CSRF check failed: missing X-Requested-With header"
)

View file

@ -1,22 +1,20 @@
"""Argon2id password hashing and verification."""
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from argon2.exceptions import VerifyMismatchError
# Argon2id is the default for PasswordHasher
ph = PasswordHasher()
def hash_password(password: str) -> str:
"""Hash a plaintext password."""
return ph.hash(password)
def verify_password(hashed: str, password: str) -> bool:
"""Verify a password against a hash. Returns True if matched."""
try:
ph.verify(hashed, password)
# Note: We skip check_needs_rehash() for simplicity in this single-user app.
return True
except (InvalidHashError, VerifyMismatchError):
except VerifyMismatchError:
return False

View file

@ -2,7 +2,6 @@
import time
from collections import defaultdict
from fastapi import HTTPException
from app.config import settings
@ -28,7 +27,7 @@ def check_rate_limit(ip: str) -> None:
detail=f"Too many failed attempts. Try again in {remaining} seconds.",
headers={"Retry-After": str(remaining)},
)
# If lockout has expired, reset
if record["lockout_until"] > 0 and record["lockout_until"] <= now:
record["attempts"] = 0

View file

@ -1,13 +1,11 @@
"""Session token generation and validation."""
from typing import Any
from fastapi import Response
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from app.config import settings
def get_serializer() -> URLSafeTimedSerializer:
"""Return a configured URLSafeTimedSerializer."""
return URLSafeTimedSerializer(settings.SECRET_KEY)
@ -16,7 +14,7 @@ def create_session(response: Response, user_id: int = 1) -> None:
"""Create a new session token and set it as an HTTP-only cookie."""
serializer = get_serializer()
token = serializer.dumps({"user_id": user_id})
response.set_cookie(
key=settings.COOKIE_NAME,
value=token,
@ -43,7 +41,7 @@ def verify_session(token: str) -> int | None:
serializer = get_serializer()
try:
data: dict[str, Any] = serializer.loads(
token,
token,
max_age=settings.COOKIE_MAX_AGE_SECONDS
)
return data.get("user_id")

View file

@ -5,7 +5,6 @@ All settings are driven by environment variables (see .env.example).
"""
from pathlib import Path
from pydantic_settings import BaseSettings

View file

@ -4,10 +4,11 @@ Database engine, session management, and WAL mode setup.
SQLite with WAL mode for single-user concurrent read/write safety.
"""
from collections.abc import Generator
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
from sqlalchemy import Engine, create_engine, event
from sqlalchemy import event, create_engine, Engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
@ -15,7 +16,6 @@ from app.config import settings
class Base(DeclarativeBase):
"""SQLAlchemy declarative base for all models."""
pass
@ -49,10 +49,10 @@ engine = create_db_engine()
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
def get_db() -> Generator[Session, None, None]:
def get_db() -> Session:
"""FastAPI dependency that yields a database session."""
db = SessionLocal()
try:
yield db
yield db # type: ignore[misc]
finally:
db.close()

View file

@ -5,8 +5,8 @@ This is the main entry point. The lifespan handler initializes the database
and ensures required directories exist on startup.
"""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator
from typing import Any
from fastapi import FastAPI, Request
@ -14,8 +14,7 @@ from fastapi.responses import JSONResponse
from app.api.v1 import router as v1_router
from app.config import settings
from app.db import Base, SessionLocal, engine
from app.services.trash_sweep import prune_auto_versions, sweep_trash
from app.db import Base, engine
@asynccontextmanager
@ -30,12 +29,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
settings.THUMBNAILS_PATH.mkdir(parents=True, exist_ok=True)
settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
# Run retention cleanup on every startup. This keeps the single-container
# deployment self-maintaining without requiring a separate scheduler.
with SessionLocal() as db:
sweep_trash(db)
prune_auto_versions(db)
yield
# Shutdown: dispose of the engine
@ -55,7 +48,6 @@ app = FastAPI(
# --- Error handlers ---
@app.exception_handler(404)
async def not_found_handler(request: Request, exc: Any) -> JSONResponse:
"""Consistent 404 error envelope."""

View file

@ -5,9 +5,9 @@ All models use UUIDv4 string primary keys (except the singleton settings row).
Timestamps are stored as ISO8601 TEXT columns.
"""
from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.models.settings import Settings
from app.models.document import Document
from app.models.annotation_state import AnnotationState
from app.models.version import Version
__all__ = ["AnnotationState", "Document", "Settings", "Version"]
__all__ = ["Settings", "Document", "AnnotationState", "Version"]

View file

@ -1,19 +1,15 @@
"""AnnotationState model — current working annotation layer per document."""
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from datetime import datetime, timezone
from sqlalchemy import ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
if TYPE_CHECKING:
from app.models.document import Document
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
return datetime.now(timezone.utc).isoformat()
class AnnotationState(Base):

View file

@ -1,25 +1,20 @@
"""Document model — uploaded PDFs with soft-delete support."""
import uuid
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from datetime import datetime, timezone
from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
if TYPE_CHECKING:
from app.models.annotation_state import AnnotationState
from app.models.version import Version
def _uuid() -> str:
return str(uuid.uuid4())
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
return datetime.now(timezone.utc).isoformat()
class Document(Base):

View file

@ -1,6 +1,6 @@
"""Settings model — singleton row for app-wide configuration."""
from datetime import UTC, datetime
from datetime import datetime, timezone
from sqlalchemy import Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
@ -22,10 +22,10 @@ class Settings(Base):
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[str] = mapped_column(
Text,
default=lambda: datetime.now(UTC).isoformat(),
default=lambda: datetime.now(timezone.utc).isoformat(),
)
updated_at: Mapped[str] = mapped_column(
Text,
default=lambda: datetime.now(UTC).isoformat(),
onupdate=lambda: datetime.now(UTC).isoformat(),
default=lambda: datetime.now(timezone.utc).isoformat(),
onupdate=lambda: datetime.now(timezone.utc).isoformat(),
)

View file

@ -1,24 +1,20 @@
"""Version model — annotation state snapshots for history/recovery."""
import uuid
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from datetime import datetime, timezone
from sqlalchemy import ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
if TYPE_CHECKING:
from app.models.document import Document
def _uuid() -> str:
return str(uuid.uuid4())
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
return datetime.now(timezone.utc).isoformat()
class Version(Base):
@ -44,4 +40,6 @@ class Version(Base):
# Relationships
document: Mapped["Document"] = relationship("Document", back_populates="versions")
__table_args__ = (Index("ix_versions_document_created", "document_id", "created_at"),)
__table_args__ = (
Index("ix_versions_document_created", "document_id", "created_at"),
)

View file

@ -1,9 +1,7 @@
from datetime import datetime
from typing import Any, Literal
from uuid import UUID
from typing import Literal, Union, List, Tuple, Optional
from pydantic import BaseModel, Field
from uuid import UUID
class Rect(BaseModel):
x: float
@ -11,7 +9,6 @@ class Rect(BaseModel):
width: float
height: float
class AnnotationBase(BaseModel):
id: UUID
page: int = Field(ge=0)
@ -22,7 +19,6 @@ class AnnotationBase(BaseModel):
createdAt: datetime
updatedAt: datetime
class TextProps(BaseModel):
text: str
fontFamily: str = "Liberation Sans"
@ -32,105 +28,82 @@ class TextProps(BaseModel):
bold: bool = False
italic: bool = False
lineHeight: float = 1.2
highlightColor: str | None = None
styles: dict[str, Any] | None = None
highlightColor: Optional[str] = None
styles: Optional[dict] = None
class TextAnnotation(AnnotationBase):
type: Literal["text"]
props: TextProps
class DrawProps(BaseModel):
paths: list[tuple[float, float]] = Field(default_factory=list)
svgPath: str | None = None
paths: List[Tuple[float, float]] = []
svgPath: Optional[str] = None
strokeColor: str = "#000000"
strokeWidth: float = 2
opacity: float = 1.0
class DrawAnnotation(AnnotationBase):
type: Literal["draw"]
props: DrawProps
class SignatureDrawProps(BaseModel):
mode: Literal["draw"]
ref: str
strokeColor: str = "#000000"
class SignatureTypeProps(BaseModel):
mode: Literal["type"]
text: str
fontFamily: str
color: str = "#000000"
class SignatureAnnotation(AnnotationBase):
type: Literal["signature"]
props: SignatureDrawProps | SignatureTypeProps = Field(discriminator="mode")
props: Union[SignatureDrawProps, SignatureTypeProps] = Field(discriminator="mode")
class ImageProps(BaseModel):
ref: str
naturalWidth: float
naturalHeight: float
class ImageAnnotation(AnnotationBase):
type: Literal["image"]
props: ImageProps
class HighlightProps(BaseModel):
color: str = "#FFEB3B"
opacity: float = 0.3
class HighlightAnnotation(AnnotationBase):
type: Literal["highlight"]
props: HighlightProps
class ShapeProps(BaseModel):
kind: Literal["rect", "ellipse", "line", "arrow"]
strokeColor: str = "#000000"
fillColor: str = "transparent"
strokeWidth: float = 2
start: tuple[float, float] | None = None
end: tuple[float, float] | None = None
class ShapeAnnotation(AnnotationBase):
type: Literal["shape"]
props: ShapeProps
KnownAnnotation = (
TextAnnotation
| DrawAnnotation
| SignatureAnnotation
| ImageAnnotation
| HighlightAnnotation
| ShapeAnnotation
)
# Working state is intentionally opaque. Keeping this payload as dictionaries
# lets newer clients round-trip annotation types that this server cannot export
# yet; the export registry skips those types with a warning.
AnnotationPayload = dict[str, Any]
Annotation = Union[
TextAnnotation,
DrawAnnotation,
SignatureAnnotation,
ImageAnnotation,
HighlightAnnotation,
ShapeAnnotation
]
class AnnotationStateResponse(BaseModel):
data: list[AnnotationPayload]
data: List[Annotation]
updatedAt: datetime
class AnnotationStateUpdateRequest(BaseModel):
data: list[AnnotationPayload]
data: List[Annotation]
baseUpdatedAt: datetime | None = None
class AnnotationStateUpdateResponse(BaseModel):
updatedAt: datetime

View file

@ -2,7 +2,6 @@
from pydantic import BaseModel, Field
class SetupRequest(BaseModel):
password: str = Field(..., min_length=8)

View file

@ -1,8 +1,7 @@
"""Pydantic schemas for Document APIs."""
from pydantic import BaseModel
from typing import Optional
class DocumentMeta(BaseModel):
id: str
@ -13,7 +12,7 @@ class DocumentMeta(BaseModel):
in_trash: bool
created_at: str
updated_at: str
deleted_at: str | None
deleted_at: Optional[str]
model_config = {"from_attributes": True}
@ -28,5 +27,5 @@ class BulkRestoreRequest(BaseModel):
ids: list[str]
class DocumentUpdateRequest(BaseModel):
title: str | None = None
in_trash: bool | None = None
title: Optional[str] = None
in_trash: Optional[bool] = None

View file

@ -1,8 +0,0 @@
"""PDF export request schema."""
from pydantic import BaseModel
class ExportRequest(BaseModel):
versionId: str | None = None
flatten: bool = True

View file

@ -1,35 +0,0 @@
"""Version checkpoint schemas."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from app.schemas.annotations import AnnotationPayload
class VersionCreateRequest(BaseModel):
label: str | None = Field(default=None, max_length=120)
kind: Literal["manual", "auto"] = "manual"
class VersionMeta(BaseModel):
id: str
documentId: str
label: str | None
kind: Literal["manual", "auto"]
createdAt: datetime
annotationCount: int
class VersionListResponse(BaseModel):
items: list[VersionMeta]
class VersionDataResponse(BaseModel):
data: list[AnnotationPayload]
meta: VersionMeta
class VersionRestoreResponse(BaseModel):
updatedAt: datetime

View file

@ -1,12 +0,0 @@
"""Storage helpers for binary annotation assets."""
import shutil
from app.config import settings
def delete_asset_directory(document_id: str) -> None:
"""Delete all uploaded annotation assets belonging to a document."""
directory = settings.PDF_STORAGE_PATH / "assets" / document_id
if directory.exists():
shutil.rmtree(directory)

View file

@ -1 +0,0 @@
"""PDF export services."""

View file

@ -1,465 +0,0 @@
"""Flatten canonical annotation data into a PDF with PyMuPDF."""
import logging
import math
import re
import uuid
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Any
import pymupdf
logger = logging.getLogger(__name__)
Annotation = Mapping[str, Any]
Handler = Callable[[pymupdf.Page, Annotation, Path], None]
def _rect(annotation: Annotation) -> pymupdf.Rect | None:
value = annotation.get("rect")
if not isinstance(value, Mapping):
return None
try:
x = float(value["x"])
y = float(value["y"])
width = float(value["width"])
height = float(value["height"])
except (KeyError, TypeError, ValueError):
return None
if width < 0 or height < 0:
return None
return pymupdf.Rect(x, y, x + width, y + height)
def _props(annotation: Annotation) -> Mapping[str, Any]:
value = annotation.get("props")
return value if isinstance(value, Mapping) else {}
def _color(
value: Any,
fallback: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> tuple[float, float, float]:
if not isinstance(value, str):
return fallback
text = value.strip().lstrip("#")
if len(text) == 3:
text = "".join(char * 2 for char in text)
if len(text) != 6:
return fallback
try:
return tuple(int(text[index : index + 2], 16) / 255 for index in (0, 2, 4)) # type: ignore[return-value]
except ValueError:
return fallback
def _opacity(value: Any, fallback: float = 1.0) -> float:
try:
return max(0.0, min(1.0, float(value)))
except (TypeError, ValueError):
return fallback
def _font_name(family: Any, bold: Any = False, italic: Any = False) -> str:
name = str(family or "Liberation Sans").lower()
is_serif = "times" in name or "serif" in name
is_mono = "courier" in name or "mono" in name
if is_serif:
normal, bold_name, italic_name, bold_italic_name = "tiro", "tibo", "tiit", "tibi"
elif is_mono:
normal, bold_name, italic_name, bold_italic_name = "cour", "cobo", "coit", "cobi"
else:
normal, bold_name, italic_name, bold_italic_name = "helv", "hebo", "heit", "hebi"
if bold and italic:
return bold_italic_name
if bold:
return bold_name
if italic:
return italic_name
return normal
_SIGNATURE_FONT_FILES = {
"great vibes": "GreatVibes-Regular.ttf",
"allura": "allura.ttf",
"sacramento": "sacramento.ttf",
"dancing script": "dancing-script.ttf",
"caveat": "caveat.ttf",
}
def _signature_font_file(family: Any) -> Path | None:
filename = _SIGNATURE_FONT_FILES.get(str(family or "").strip().lower())
if not filename:
return None
path = Path(__file__).parents[2] / "assets" / "fonts" / filename
return path if path.is_file() else None
def _signature_font_size(
rect: pymupdf.Rect, text: str, font_file: Path | None, font_name: str
) -> float:
size = max(1.0, min(48.0, rect.height * 0.55))
try:
font = (
pymupdf.Font(fontfile=str(font_file)) if font_file else pymupdf.Font(fontname=font_name)
)
text_width = font.text_length(text, fontsize=size)
if text_width > rect.width and text_width > 0:
size *= max(0.1, rect.width / text_width) * 0.95
except Exception:
# A missing optional font must not prevent a PDF export.
pass
return max(1.0, size)
def _points_from_props(annotation: Annotation, rect: pymupdf.Rect) -> list[pymupdf.Point]:
props = _props(annotation)
raw_points = props.get("paths")
points: list[pymupdf.Point] = []
if isinstance(raw_points, Sequence) and not isinstance(raw_points, (str, bytes)):
for raw_point in raw_points:
if isinstance(raw_point, Sequence) and len(raw_point) == 2:
try:
points.append(
pymupdf.Point(
rect.x0 + float(raw_point[0]),
rect.y0 + float(raw_point[1]),
)
)
except (TypeError, ValueError):
continue
if len(points) >= 2:
return points
# Older clients stored a Fabric SVG path. Approximate its command end
# points and normalize them into the canonical annotation rectangle.
svg_path = props.get("svgPath")
if not isinstance(svg_path, str):
return []
raw = [float(value) for value in re.findall(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", svg_path)]
if len(raw) < 4:
return []
pairs = list(zip(raw[::2], raw[1::2], strict=False))
min_x = min(point[0] for point in pairs)
max_x = max(point[0] for point in pairs)
min_y = min(point[1] for point in pairs)
max_y = max(point[1] for point in pairs)
source_width = max(max_x - min_x, 1e-6)
source_height = max(max_y - min_y, 1e-6)
return [
pymupdf.Point(
rect.x0 + (x - min_x) / source_width * rect.width,
rect.y0 + (y - min_y) / source_height * rect.height,
)
for x, y in pairs
]
def _draw_polyline(
page: pymupdf.Page,
points: list[pymupdf.Point],
*,
color: tuple[float, float, float],
width: float,
opacity: float,
) -> None:
if len(points) < 2:
return
shape = page.new_shape()
shape.draw_polyline(points)
shape.finish(color=color, width=max(0.1, width), stroke_opacity=opacity)
shape.commit(overlay=True)
def _style_at(styles: Any, line_number: int, character_number: int) -> Mapping[str, Any]:
if not isinstance(styles, Mapping):
return {}
line_styles = styles.get(str(line_number), styles.get(line_number))
if not isinstance(line_styles, Mapping):
return {}
character_style = line_styles.get(str(character_number), line_styles.get(character_number))
return character_style if isinstance(character_style, Mapping) else {}
def _render_styled_text(
page: pymupdf.Page,
rect: pymupdf.Rect,
text: str,
props: Mapping[str, Any],
styles: Any,
) -> bool:
if not isinstance(styles, Mapping) or not styles:
return False
base_size = max(1.0, float(props.get("fontSize", 14)))
line_height = max(0.5, float(props.get("lineHeight", 1.2)))
lines = text.split("\n")
y = rect.y0
for line_number, line in enumerate(lines):
measured: list[tuple[str, float, str, tuple[float, float, float], float, str | None]] = []
for character_number, character in enumerate(line):
style = _style_at(styles, line_number, character_number)
font_size = max(1.0, float(style.get("fontSize", base_size)))
font_family = style.get("fontFamily", props.get("fontFamily"))
font_name = _font_name(
font_family,
style.get("fontWeight") == "bold",
style.get("fontStyle") == "italic",
)
color = _color(style.get("fill", props.get("color")))
font = pymupdf.Font(fontname=font_name)
width = float(font.text_length(character, fontsize=font_size))
background = style.get("textBackgroundColor", props.get("highlightColor"))
measured.append((character, font_size, font_name, color, width, background))
total_width = sum(item[4] for item in measured)
align = str(props.get("align", "left"))
x = rect.x0
if align == "center":
x += max(0.0, (rect.width - total_width) / 2)
elif align == "right":
x += max(0.0, rect.width - total_width)
max_size = max((item[1] for item in measured), default=base_size)
for character, font_size, font_name, color, width, background in measured:
if (
isinstance(background, str)
and background.lower() not in {"", "transparent", "none"}
):
page.draw_rect(
pymupdf.Rect(x, y, x + width, y + max_size * 1.15),
color=None,
fill=_color(background),
fill_opacity=0.3,
overlay=True,
)
if character != " ":
page.insert_text(
pymupdf.Point(x, y + font_size),
character,
fontsize=font_size,
fontname=font_name,
color=color,
overlay=True,
)
x += width
y += max_size * line_height
return True
def render_text(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
text = str(props.get("text", ""))
if not text:
return
highlight = props.get("highlightColor")
if isinstance(highlight, str) and highlight and highlight.lower() != "transparent":
page.draw_rect(rect, color=None, fill=_color(highlight), fill_opacity=0.3, overlay=True)
if _render_styled_text(page, rect, text, props, props.get("styles")):
return
align = {"left": 0, "center": 1, "right": 2}.get(str(props.get("align", "left")), 0)
page.insert_textbox(
rect,
text,
fontsize=max(1.0, float(props.get("fontSize", 14))),
fontname=_font_name(props.get("fontFamily"), props.get("bold"), props.get("italic")),
color=_color(props.get("color")),
align=align,
overlay=True,
)
def render_draw(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
_draw_polyline(
page,
_points_from_props(annotation, rect),
color=_color(props.get("strokeColor")),
width=float(props.get("strokeWidth", 2)),
opacity=_opacity(props.get("opacity")),
)
def _asset_path(assets_dir: Path, ref: Any) -> Path | None:
if not isinstance(ref, str):
return None
try:
safe_ref = str(uuid.UUID(ref))
except ValueError:
return None
path = assets_dir / safe_ref
return path if path.is_file() else None
def render_image(page: pymupdf.Page, annotation: Annotation, assets_dir: Path) -> None:
rect = _rect(annotation)
asset = _asset_path(assets_dir, _props(annotation).get("ref"))
if rect is None or asset is None:
return
page.insert_image(rect, filename=str(asset), keep_proportion=False, overlay=True)
def render_signature(page: pymupdf.Page, annotation: Annotation, assets_dir: Path) -> None:
rect = _rect(annotation)
props = _props(annotation)
if rect is None:
return
if props.get("mode") == "draw":
asset = _asset_path(assets_dir, props.get("ref"))
if asset is not None:
page.insert_image(rect, filename=str(asset), keep_proportion=False, overlay=True)
return
text = str(props.get("text", ""))
if text:
font_file = _signature_font_file(props.get("fontFamily"))
font_name = _font_name(props.get("fontFamily"), False, False)
options: dict[str, Any] = {
# Script fonts have a taller ascender than the built-in PDF fonts;
# leave room inside the editor's saved bounding rectangle so a
# valid signature is never silently dropped for not fitting.
"fontsize": _signature_font_size(rect, text, font_file, font_name),
"color": _color(props.get("color")),
"overlay": True,
}
if font_file:
options["fontname"] = f"paperjet-{font_file.stem.lower()}"
options["fontfile"] = str(font_file)
else:
options["fontname"] = font_name
page.insert_textbox(rect, text, **options)
def render_highlight(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
page.draw_rect(
rect,
color=None,
fill=_color(props.get("color"), (1.0, 0.92, 0.1)),
fill_opacity=_opacity(props.get("opacity"), 0.3),
overlay=True,
)
def render_shape(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None:
rect = _rect(annotation)
if rect is None:
return
props = _props(annotation)
kind = str(props.get("kind", "rect"))
stroke = _color(props.get("strokeColor"))
fill_value = props.get("fillColor")
fill = None if fill_value in (None, "", "transparent", "none") else _color(fill_value)
width = max(0.1, float(props.get("strokeWidth", 2)))
if kind == "ellipse":
page.draw_oval(rect, color=stroke, fill=fill, width=width, overlay=True)
elif kind in {"line", "arrow"}:
start = props.get("start")
end = props.get("end")
if (
isinstance(start, Sequence)
and not isinstance(start, (str, bytes))
and len(start) == 2
and isinstance(end, Sequence)
and not isinstance(end, (str, bytes))
and len(end) == 2
):
try:
start_point = pymupdf.Point(
rect.x0 + float(start[0]) * rect.width,
rect.y0 + float(start[1]) * rect.height,
)
end_point = pymupdf.Point(
rect.x0 + float(end[0]) * rect.width,
rect.y0 + float(end[1]) * rect.height,
)
except (TypeError, ValueError):
start_point, end_point = rect.tl, rect.br
else:
start_point, end_point = rect.tl, rect.br
page.draw_line(start_point, end_point, color=stroke, width=width, overlay=True)
if kind == "arrow":
angle = math.atan2(end_point.y - start_point.y, end_point.x - start_point.x)
length = min(
12.0,
max(
5.0, math.hypot(end_point.x - start_point.x, end_point.y - start_point.y) * 0.2
),
)
left = pymupdf.Point(
end_point.x - length * math.cos(angle - math.pi / 6),
end_point.y - length * math.sin(angle - math.pi / 6),
)
right = pymupdf.Point(
end_point.x - length * math.cos(angle + math.pi / 6),
end_point.y - length * math.sin(angle + math.pi / 6),
)
page.draw_polyline([left, end_point, right], color=stroke, width=width, overlay=True)
else:
page.draw_rect(rect, color=stroke, fill=fill, width=width, overlay=True)
HANDLERS: dict[str, Handler] = {
"text": render_text,
"draw": render_draw,
"signature": render_signature,
"image": render_image,
"highlight": render_highlight,
"shape": render_shape,
}
def export_annotations(
pdf_path: Path,
annotations: Sequence[Annotation],
assets_dir: Path,
) -> bytes:
"""Return a flattened PDF while preserving the source page rotations."""
with pymupdf.open(pdf_path) as document:
rotations = [page.rotation for page in document]
try:
for page in document:
if page.rotation:
page.set_rotation(0)
page_annotations: dict[int, list[Annotation]] = {}
for annotation in annotations:
try:
page_number = int(annotation.get("page", -1))
except (TypeError, ValueError):
logger.warning("Skipping annotation with invalid page: %r", annotation)
continue
if 0 <= page_number < len(document):
page_annotations.setdefault(page_number, []).append(annotation)
else:
logger.warning("Skipping annotation outside document pages: %r", annotation)
for page_number, page in enumerate(document):
ordered = sorted(
page_annotations.get(page_number, []),
key=lambda item: int(item.get("z", 0)),
)
for annotation in ordered:
annotation_type = annotation.get("type")
handler = HANDLERS.get(str(annotation_type))
if handler is None:
logger.warning("Skipping unsupported annotation type %r", annotation_type)
continue
try:
handler(page, annotation, assets_dir)
except Exception:
logger.exception("Skipping malformed %s annotation", annotation_type)
finally:
for page, rotation in zip(document, rotations, strict=True):
if page.rotation != rotation:
page.set_rotation(rotation)
return document.tobytes(garbage=4, deflate=True)

View file

@ -1,71 +1,53 @@
"""Document and binary storage helpers."""
"""Document storage service."""
import shutil
import uuid
from pathlib import Path
import pymupdf
from fastapi import HTTPException, UploadFile
from pathlib import Path
from fastapi import UploadFile, HTTPException
from app.config import settings
_COPY_CHUNK_SIZE = 1024 * 1024
def validate_pdf(filepath: Path) -> int:
"""Validate a PDF's magic bytes and return its page count."""
with filepath.open("rb") as file:
if file.read(5) != b"%PDF-":
def validate_pdf(filepath: Path) -> None:
"""Validate PDF magic bytes and PyMuPDF openability."""
with open(filepath, "rb") as f:
header = f.read(5)
if header != b"%PDF-":
raise ValueError("Not a valid PDF file (missing magic bytes)")
try:
with pymupdf.open(filepath) as document:
page_count = len(document)
except Exception as err:
raise ValueError(f"Failed to open PDF with PyMuPDF: {err}") from err
if page_count == 0:
raise ValueError("PDF does not contain any pages")
return page_count
def safe_filename(filename: str | None) -> str:
"""Return a display-safe basename without allowing path components."""
name = Path(filename or "Untitled.pdf").name.strip()
return name or "Untitled.pdf"
doc = pymupdf.open(filepath)
doc.close()
except Exception as e:
raise ValueError(f"Failed to open PDF with PyMuPDF: {e}")
async def save_upload_file(upload_file: UploadFile) -> tuple[str, int, int]:
"""Stream an uploaded PDF to storage, enforcing the configured size limit."""
"""Save an uploaded file to disk and validate it."""
file_id = str(uuid.uuid4())
settings.PDF_STORAGE_PATH.mkdir(parents=True, exist_ok=True)
filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
max_bytes = settings.MAX_UPLOAD_MB * 1024 * 1024
total_bytes = 0
try:
with filepath.open("wb") as destination:
while chunk := upload_file.file.read(_COPY_CHUNK_SIZE):
total_bytes += len(chunk)
if total_bytes > max_bytes:
raise HTTPException(status_code=413, detail="File too large")
destination.write(chunk)
except HTTPException:
filepath.unlink(missing_ok=True)
raise
except Exception as err:
filepath.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="Failed to save file") from err
with open(filepath, "wb") as f:
shutil.copyfileobj(upload_file.file, f)
except Exception as e:
if filepath.exists():
filepath.unlink()
raise HTTPException(status_code=500, detail="Failed to save file")
page_count = 0
try:
page_count = validate_pdf(filepath)
except ValueError as err:
filepath.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail=str(err)) from err
return file_id, total_bytes, page_count
doc = pymupdf.open(filepath)
page_count = len(doc)
doc.close()
except Exception as e:
filepath.unlink()
raise HTTPException(status_code=400, detail=f"Failed to open PDF with PyMuPDF: {e}")
size = filepath.stat().st_size
return file_id, size, page_count
def delete_pdf_file(file_id: str) -> None:
"""Delete a PDF file from storage."""
filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
filepath.unlink(missing_ok=True)
if filepath.exists():
filepath.unlink()

View file

@ -1,40 +1,38 @@
"""Thumbnail generation service."""
import pymupdf
from pathlib import Path
from app.config import settings
def generate_thumbnail(file_id: str) -> bool:
def generate_thumbnail(file_id: str) -> None:
"""Generate a PNG thumbnail for the first page of a PDF."""
pdf_path = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png"
if not pdf_path.exists():
return False
return
try:
with pymupdf.open(pdf_path) as doc:
if len(doc) == 0:
return False
doc = pymupdf.open(pdf_path)
if len(doc) > 0:
page = doc[0]
# Zoom to approximately 600px width
rect = page.rect
zoom = 600.0 / rect.width if rect.width > 0 else 1.0
mat = pymupdf.Matrix(zoom, zoom)
# Render pixmap
pix = page.get_pixmap(matrix=mat, alpha=False)
# Save as PNG
pix.save(thumb_path, output="png")
return True
except Exception as err:
print(f"Failed to generate thumbnail for {file_id}: {err}")
return False
doc.close()
except Exception as e:
print(f"Failed to generate thumbnail for {file_id}: {e}")
def delete_thumbnail(file_id: str) -> None:
"""Delete a thumbnail file."""
thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png"
thumb_path.unlink(missing_ok=True)
if thumb_path.exists():
thumb_path.unlink()

View file

@ -1,50 +1,48 @@
"""Background tasks for sweeping trash and old versions."""
from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta, timezone
from sqlalchemy.orm import Session
from app.config import settings
from app.models.annotation_state import AnnotationState
from app.db import get_db
from app.models.document import Document
from app.models.annotation_state import AnnotationState
from app.models.version import Version
from app.services.assets import delete_asset_directory
from app.services.storage import delete_pdf_file
from app.services.thumbnails import delete_thumbnail
from app.config import settings
def sweep_trash(db: Session) -> None:
"""Permanently delete documents that have been in the trash past the retention period."""
cutoff = datetime.now(UTC) - timedelta(days=settings.TRASH_RETENTION_DAYS)
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.TRASH_RETENTION_DAYS)
cutoff_iso = cutoff.isoformat()
docs_to_delete = (
db.query(Document)
.filter(Document.deleted_at.is_not(None), Document.deleted_at <= cutoff_iso)
.all()
)
docs_to_delete = db.query(Document).filter(
Document.in_trash == True,
Document.deleted_at <= cutoff_iso
).all()
for doc in docs_to_delete:
# Delete files from disk
delete_pdf_file(doc.id)
delete_thumbnail(doc.id)
delete_asset_directory(doc.id)
# Delete DB associations
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
db.query(Version).filter(Version.document_id == doc.id).delete()
# Delete document record
db.delete(doc)
db.commit()
def prune_auto_versions(db: Session) -> None:
"""Delete automatic versions older than the retention period."""
cutoff = datetime.now(UTC) - timedelta(days=settings.AUTO_VERSION_RETENTION_DAYS)
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.AUTO_VERSION_RETENTION_DAYS)
cutoff_iso = cutoff.isoformat()
db.query(Version).filter(Version.kind == "auto", Version.created_at <= cutoff_iso).delete()
db.query(Version).filter(
Version.kind == "auto",
Version.created_at <= cutoff_iso
).delete()
db.commit()

View file

@ -1,121 +0,0 @@
"""End-to-end coverage for the user-critical backend workflows."""
import os
import shutil
import uuid
from pathlib import Path
import pymupdf
from fastapi.testclient import TestClient
_ROOT = Path.cwd() / ".pytest-paperjet-api"
shutil.rmtree(_ROOT, ignore_errors=True)
_ROOT.mkdir()
os.environ.update(
{
"PAPERJET_SECRET_KEY": "test-secret-key",
"PAPERJET_DATABASE_PATH": str(_ROOT / "db" / "app.sqlite"),
"PAPERJET_PDF_STORAGE_PATH": str(_ROOT / "pdfs"),
"PAPERJET_THUMBNAILS_PATH": str(_ROOT / "thumbnails"),
"PAPERJET_DEBUG": "false",
}
)
from app.main import app # noqa: E402
def _pdf_bytes() -> bytes:
document = pymupdf.open()
document.new_page(width=240, height=320)
content = document.tobytes()
document.close()
return content
def test_setup_upload_annotate_version_export_and_trash_restore() -> None:
csrf = {"X-Requested-With": "XMLHttpRequest"}
annotation_id = str(uuid.uuid4())
annotation = {
"id": annotation_id,
"page": 0,
"type": "text",
"rect": {"x": 20, "y": 30, "width": 120, "height": 30},
"rotation": 0,
"z": 0,
"props": {
"text": "Persisted note",
"fontFamily": "Liberation Sans",
"fontSize": 14,
"color": "#000000",
},
"createdAt": "2026-08-14T00:00:00Z",
"updatedAt": "2026-08-14T00:00:00Z",
}
with TestClient(app) as client:
assert client.get("/api/v1/health").json()["status"] == "ok"
assert client.get("/api/v1/auth/status").json()["setupRequired"] is True
setup = client.post("/api/v1/auth/setup", json={"password": "correct horse"}, headers=csrf)
assert setup.status_code == 200
assert client.get("/api/v1/auth/status").json()["loggedIn"] is True
upload = client.post(
"/api/v1/documents",
files={"file": ("sample.pdf", _pdf_bytes(), "application/pdf")},
headers=csrf,
)
assert upload.status_code == 201
document_id = upload.json()["id"]
saved = client.put(
f"/api/v1/documents/{document_id}/annotations",
json={"data": [annotation]},
headers=csrf,
)
assert saved.status_code == 200
assert client.get(f"/api/v1/documents/{document_id}/annotations").json()["data"] == [
annotation
]
version = client.post(
f"/api/v1/documents/{document_id}/versions",
json={"label": "Before export"},
headers=csrf,
)
assert version.status_code == 201
version_id = version.json()["id"]
changed = {**annotation, "props": {**annotation["props"], "text": "Changed"}}
client.put(
f"/api/v1/documents/{document_id}/annotations",
json={"data": [changed]},
headers=csrf,
)
restored = client.post(
f"/api/v1/documents/{document_id}/versions/{version_id}/restore",
headers=csrf,
)
assert restored.status_code == 200
assert client.get(f"/api/v1/documents/{document_id}/annotations").json()["data"] == [
annotation
]
exported = client.post(
f"/api/v1/documents/{document_id}/export",
json={"flatten": True},
headers=csrf,
)
assert exported.status_code == 200
with pymupdf.open(stream=exported.content, filetype="pdf") as document:
assert "Persisted note" in document[0].get_text("text")
assert client.delete(f"/api/v1/documents/{document_id}", headers=csrf).status_code == 200
assert client.get("/api/v1/documents").json()["total"] == 0
assert client.get("/api/v1/documents/trash").json()["total"] == 1
assert (
client.post(f"/api/v1/documents/{document_id}/restore", headers=csrf).status_code == 200
)
assert client.get("/api/v1/documents").json()["total"] == 1
shutil.rmtree(_ROOT, ignore_errors=True)

View file

@ -1,121 +0,0 @@
"""Regression coverage for canonical-coordinate PDF export."""
import json
import pymupdf
from app.services.export.renderer import export_annotations
def _source_pdf(path) -> None:
document = pymupdf.open()
page = document.new_page(width=720, height=936)
page.set_cropbox(pymupdf.Rect(36, 72, 648, 864))
page.set_rotation(90)
document.save(path)
document.close()
def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path) -> None:
source = tmp_path / "source.pdf"
_source_pdf(source)
annotations = [
{
"id": "text",
"page": 0,
"type": "text",
"rect": {"x": 40, "y": 50, "width": 180, "height": 40},
"props": {
"text": "Exported text",
"fontFamily": "Liberation Sans",
"fontSize": 14,
"color": "#112233",
"styles": {"0": {"0": {"fill": "#ff0000", "fontWeight": "bold"}}},
},
},
{
"id": "line",
"page": 0,
"type": "draw",
"rect": {"x": 40, "y": 110, "width": 80, "height": 40},
"props": {
"paths": [[0, 0], [80, 40]],
"strokeColor": "#ff0000",
"strokeWidth": 2,
"opacity": 1,
},
},
{
"id": "highlight",
"page": 0,
"type": "highlight",
"rect": {"x": 40, "y": 170, "width": 100, "height": 20},
"props": {"color": "#ffff00", "opacity": 0.4},
},
{
"id": "signature",
"page": 0,
"type": "signature",
"rect": {"x": 40, "y": 220, "width": 160, "height": 50},
"props": {
"mode": "type",
"text": "Ava",
"fontFamily": "Great Vibes",
"color": "#000000",
},
},
{
"id": "shape",
"page": 0,
"type": "shape",
"rect": {"x": 180, "y": 170, "width": 60, "height": 30},
"props": {"kind": "ellipse", "strokeColor": "#0000ff", "strokeWidth": 2},
},
{
"id": "future",
"page": 0,
"type": "future-stamp",
"rect": {"x": 0, "y": 0, "width": 10, "height": 10},
"props": {},
},
]
exported = export_annotations(source, annotations, tmp_path / "assets")
output = tmp_path / "exported.pdf"
output.write_bytes(exported)
with pymupdf.open(output) as document:
page = document[0]
assert page.rotation == 90
assert page.rect.width == 792
assert page.rect.height == 612
assert "Exported text" in page.get_text("text")
assert "Ava" in page.get_text("text")
# Normalize only for inspection: the exported PDF still retains the
# original page rotation above.
page.set_rotation(0)
words = page.get_text("words")
text_word = next(word for word in words if word[4] == "Exported")
assert 35 <= text_word[0] <= 45
assert 45 <= text_word[1] <= 60
assert len(page.get_drawings()) >= 3
def test_export_does_not_mutate_annotation_input(tmp_path) -> None:
source = tmp_path / "source.pdf"
_source_pdf(source)
annotations = [
{
"id": "unknown",
"page": 0,
"type": "not-yet-supported",
"rect": {"x": 1, "y": 2, "width": 3, "height": 4},
"props": {"future": True},
}
]
before = json.loads(json.dumps(annotations))
export_annotations(source, annotations, tmp_path / "assets")
assert annotations == before

View file

@ -30,18 +30,12 @@ dev = [
requires = ["setuptools>=75.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
# FastAPI requires dependency and multipart declarations in parameter defaults;
# B008 treats that framework idiom as a mutable-default hazard.
ignore = ["B008"]
[tool.mypy]
python_version = "3.12"
@ -49,21 +43,6 @@ strict = true
warn_return_any = true
warn_unused_configs = true
# PyMuPDF does not ship complete type information. Keep strict checking for
# application code while isolating its untyped boundary and test fixtures.
[[tool.mypy.overrides]]
module = [
"app.services.export.renderer",
"app.services.storage",
"app.services.thumbnails",
"app.api.v1.debug",
]
disable_error_code = ["no-untyped-call", "no-any-return", "no-untyped-def"]
[[tool.mypy.overrides]]
module = ["app.tests.*"]
disable_error_code = ["no-untyped-call", "no-any-return", "no-untyped-def", "dict-item"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["app/tests"]

View file

@ -1,23 +1,27 @@
services:
paperjet:
image: ${PAPERJET_IMAGE:-paperjet:local}
build:
context: .
dockerfile: Dockerfile
backend:
build: ./backend
environment:
PAPERJET_SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting PaperJet}
PAPERJET_MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-200}
PAPERJET_TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
PAPERJET_AUTO_VERSION_RETENTION_DAYS: ${AUTO_VERSION_RETENTION_DAYS:-30}
PAPERJET_COOKIE_SECURE: ${COOKIE_SECURE:-false}
PAPERJET_DATABASE_PATH: /data/db/app.sqlite
PAPERJET_PDF_STORAGE_PATH: /data/pdfs
PAPERJET_THUMBNAILS_PATH: /data/thumbnails
PAPERJET_DEBUG: ${DEBUG:-false}
- PAPERJET_SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- PAPERJET_MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200}
- PAPERJET_TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30}
- PAPERJET_AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30}
- PAPERJET_COOKIE_SECURE=${COOKIE_SECURE:-false}
- PAPERJET_DATABASE_PATH=/data/db/app.sqlite
- PAPERJET_PDF_STORAGE_PATH=/data/pdfs
- PAPERJET_THUMBNAILS_PATH=/data/thumbnails
- PAPERJET_DEBUG=true
volumes:
- pdf_storage:/data/pdfs
- thumbnails:/data/thumbnails
- db:/data/db
ports:
- "8000:8000"
restart: unless-stopped
frontend:
build: ./frontend
depends_on: [backend]
ports:
- "${HTTP_PORT:-4982}:80"
restart: unless-stopped

View file

@ -1,36 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
uvicorn --app-dir /app app.main:app \
--host 127.0.0.1 \
--port 8000 \
--workers 1 &
backend_pid=$!
nginx -g "daemon off;" &
nginx_pid=$!
stop_children() {
kill -TERM "$backend_pid" "$nginx_pid" 2>/dev/null || true
wait "$backend_pid" 2>/dev/null || true
wait "$nginx_pid" 2>/dev/null || true
}
shutdown() {
trap - TERM INT
stop_children
exit 143
}
trap shutdown TERM INT
# If either service exits, stop the other one and let Docker restart/report the
# container rather than leaving a partially working instance running.
set +e
wait -n "$backend_pid" "$nginx_pid"
exit_code=$?
set -e
stop_children
exit "$exit_code"

24
frontend/Dockerfile Normal file
View file

@ -0,0 +1,24 @@
# Stage 1: Build the SPA
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
# Stage 2: Serve via nginx
FROM nginx:alpine
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Copy our nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built SPA from build stage
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -1,20 +1,73 @@
# PaperJet frontend
# React + TypeScript + Vite
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.
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Useful commands
Currently, two official plugins are available:
```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
- [@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...
},
},
])
```
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.
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...
},
},
])
```

View file

@ -2,12 +2,10 @@ server {
listen 80;
server_name _;
access_log /dev/stdout;
error_log /dev/stderr warn;
include /etc/nginx/mime.types;
types {
application/javascript mjs;
application/wasm wasm;
}
# Match MAX_UPLOAD_MB raise in both this and the outer reverse proxy
@ -27,9 +25,9 @@ server {
try_files $uri $uri/ /index.html;
}
# Proxy /api/ to the loopback-only FastAPI process in this container
# Proxy /api/ to the backend container
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View file

@ -14,7 +14,7 @@ export const LoginForm = () => {
try {
await login({ password })
navigate('/')
} catch {
} catch (err) {
// Error is handled by the store
}
}

View file

@ -26,7 +26,7 @@ export const SetupForm = () => {
try {
await setup({ password })
navigate('/')
} catch {
} catch (err) {
// Error is handled by the store
}
}

View file

@ -16,17 +16,6 @@ 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,
@ -44,9 +33,9 @@ export const useAuth = create<AuthState>((set) => ({
isInitialized: true,
isLoading: false
})
} catch (err: unknown) {
} catch (err: any) {
set({
error: errorMessage(err, 'Failed to initialize'),
error: err.error?.message || err.message || 'Failed to initialize',
isLoading: false,
isInitialized: true
})
@ -58,9 +47,9 @@ export const useAuth = create<AuthState>((set) => ({
set({ isLoading: true, error: null })
await authApi.login(data)
set({ loggedIn: true, isLoading: false })
} catch (err: unknown) {
} catch (err: any) {
set({
error: errorMessage(err, 'Failed to login'),
error: err.error?.message || err.message || 'Failed to login',
isLoading: false
})
throw err
@ -72,9 +61,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: unknown) {
} catch (err: any) {
set({
error: errorMessage(err, 'Failed to setup'),
error: err.error?.message || err.message || 'Failed to setup',
isLoading: false
})
throw err
@ -87,9 +76,9 @@ export const useAuth = create<AuthState>((set) => ({
await authApi.logout()
set({ loggedIn: false, isLoading: false })
window.location.href = '/login'
} catch (err: unknown) {
} catch (err: any) {
set({
error: errorMessage(err, 'Failed to logout'),
error: err.error?.message || err.message || 'Failed to logout',
isLoading: false
})
}

View file

@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import * as fabric from 'fabric';
import { useEditorStore } from '../store';
import { getTool } from '../../../lib/annotations/registry';
import type { ViewportParams } from '../../../lib/coords';
import { screenRectToPdf } from '../../../lib/coords';
import { screenToPdf } from '../../../lib/coords';
import { TextFormatToolbar } from '../toolbar/TextFormatToolbar';
interface AnnotationLayerProps {
@ -13,201 +13,208 @@ 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 renderingIds = useRef(new Set<string>());
const renderGeneration = useRef(0);
const activeToolName = useEditorStore(state => state.activeTool);
const annotations = useEditorStore(state => state.annotations);
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,
});
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,
),
});
(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 }
});
}
}
});
const updateSelection = () => {
const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
useEditorStore.getState().setSelection(activeObject?.id ?? null);
const updateSelection = (_e?: any) => {
const activeObj = canvas.getActiveObject() as any;
if (activeObj && activeObj.id) {
useEditorStore.getState().setSelection(activeObj.id);
} else {
useEditorStore.getState().setSelection(null);
}
};
canvas.on('selection:created', updateSelection);
canvas.on('selection:updated', updateSelection);
canvas.on('selection:cleared', updateSelection);
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);
// 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();
}
}
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);
};
// 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
}, []);
}, []); // Run once on mount
const prevScaleRef = useRef(viewportParams.scale);
// Handle dimensions and re-rendering annotations
useEffect(() => {
if (!fabricRef.current) return;
const canvas = fabricRef.current;
if (!canvas) return;
const generation = ++renderGeneration.current;
renderingIds.current.clear();
canvas.setDimensions({ width, height });
const activeObj = canvas.getActiveObject() as any;
const activeId = activeObj?.id;
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 pageAnns = annotations.filter(a => a.page === pageNumber);
const annIds = new Set(pageAnns.map(a => a.id));
for (const object of canvas.getObjects() as AnnotationObject[]) {
if (!object.id || !annotationIds.has(object.id) || scaleChanged) {
canvas.remove(object);
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);
}
}
});
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);
});
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]);
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]);
// Handle tool activation/deactivation and event binding
useEffect(() => {
const canvas = fabricRef.current;
if (!canvas) return;
// 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.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) {
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));
}
}
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 () => {
if (tool && tool.onDeactivate) {
tool.onDeactivate(canvas);
}
};
}, [activeToolName]);
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;
return (
<div className="absolute left-0 top-0" style={{ width, height }}>
<div className="absolute top-0 left-0" style={{ width, height }}>
<canvas ref={canvasRef} />
{activeAnnotation?.type === 'text' && canvasInstance && (
<TextFormatToolbar
annotationId={activeAnnotation.id}
viewportParams={viewportParams}
canvas={canvasInstance}
/>
{toolbarAnnotationId && activeAnn && (
<>
{activeAnn.type === 'text' && <TextFormatToolbar annotationId={toolbarAnnotationId} viewportParams={viewportParams} />}
{/* We will add ShapeControls and DrawControls here once they are implemented */}
</>
)}
</div>
);

View file

@ -1,127 +1,87 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
import { AnnotationLayer } from '../canvas/AnnotationLayer';
interface PageRendererProps {
pdfDoc: PDFDocumentProxy;
pageIndex: number;
pageNumber: number;
scale: number;
dpr: number;
}
export function PageRenderer({ pdfDoc, pageIndex, scale, dpr }: PageRendererProps) {
export function PageRenderer({ pdfDoc, pageNumber, scale, dpr }: PageRendererProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [page, setPage] = useState<PDFPageProxy | null>(null);
useEffect(() => {
let active = true;
const pageNumber = pageIndex + 1;
pdfDoc.getPage(pageNumber).then((nextPage) => {
if (active) setPage(nextPage);
pdfDoc.getPage(pageNumber).then(p => {
if (active) setPage(p);
});
return () => {
active = false;
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,
};
}, [pdfDoc, pageIndex]);
let renderTask = page.render(renderContext);
return () => {
renderTask.cancel();
};
}, [page, scale, dpr]);
if (!page) {
return (
<div
className="flex items-center justify-center border border-neutral-200 bg-white text-neutral-400 shadow-sm"
<div
className="bg-white shadow-sm flex items-center justify-center text-neutral-400 border border-neutral-200"
style={{ width: 612 * scale, height: 792 * scale }}
>
Loading page {pageIndex + 1}...
Loading page {pageNumber}...
</div>
);
}
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;
const baseViewport = page ? page.getViewport({ scale: 1, rotation: 0 }) : null;
return (
<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 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}
</div>
</div>
);

View file

@ -8,13 +8,12 @@ interface PageStackProps {
export function PageStack({ pdfDoc }: PageStackProps) {
const numPages = pdfDoc.numPages;
const pages = Array.from({ length: numPages }, (_, i) => i);
const pages = Array.from({ length: numPages }, (_, i) => i + 1);
const zoom = useEditorStore(state => state.zoom);
// 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;
// For now, render all pages vertically. Virtualization comes in Phase 7.
const scale = zoom * 2.0;
const dpr = window.devicePixelRatio || 1;
return (
@ -23,7 +22,7 @@ export function PageStack({ pdfDoc }: PageStackProps) {
<PageRenderer
key={pageNum}
pdfDoc={pdfDoc}
pageIndex={pageNum}
pageNumber={pageNum}
scale={scale}
dpr={dpr}
/>

View file

@ -4,61 +4,50 @@ 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 [loaded, setLoaded] = useState<LoadedDocument | null>(null);
const [error, setError] = useState<DocumentError | null>(null);
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
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 });
}
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);
});
return () => {
active = false;
void loadingTask.destroy();
loadingTask.destroy();
};
}, [documentId]);
const currentError = error?.id === documentId ? error.message : null;
const currentDocument = loaded?.id === documentId ? loaded.document : null;
if (currentError) {
if (error) {
return (
<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 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>
);
}
if (!currentDocument) {
if (!pdfDoc) {
return (
<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 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>
);
}
return <PageStack pdfDoc={currentDocument} />;
return <PageStack pdfDoc={pdfDoc} />;
}

View file

@ -1,56 +0,0 @@
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([]);
});
});

View file

@ -1,171 +1,74 @@
import { create } from 'zustand';
import type {
Annotation,
SignatureDrawProps,
SignatureTypeProps,
TextProps,
} from '../../lib/annotations/types';
import type { Annotation, TextProps } from '../../lib/annotations/types';
export type SaveStatus = 'idle' | 'loading' | 'saving' | 'saved' | 'error';
type SignatureProps = SignatureDrawProps | SignatureTypeProps;
type PendingImage = { ref: string; width: number; height: number };
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
interface EditorState {
documentId: string | null;
annotations: Annotation[];
past: Annotation[][];
future: Annotation[][];
selection: string | null;
activeTool: string;
activeShapeKind: 'rect' | 'ellipse' | 'line' | 'arrow';
selection: string | null;
zoom: number;
saveStatus: SaveStatus;
annotationUpdatedAt: string | null;
defaultTextProps: Partial<TextProps>;
draftAnnotation: Annotation | null;
isSignatureModalOpen: boolean;
pendingSignatureProps: SignatureProps | null;
pendingImageRef: PendingImage | null;
pendingSignatureProps: any | null;
pendingImageRef: { ref: string, width: number, height: number } | null;
// Actions
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: (annotation: Annotation | null) => void;
setDraftAnnotation: (ann: Annotation | null) => void;
setIsSignatureModalOpen: (isOpen: boolean) => void;
setPendingSignatureProps: (props: SignatureProps | null) => void;
setPendingImageRef: (image: PendingImage | null) => void;
setActiveShapeKind: (kind: EditorState['activeShapeKind']) => 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;
}
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) => ({
export const useEditorStore = create<EditorState>((set) => ({
documentId: null,
annotations: [],
past: [],
future: [],
selection: null,
activeTool: 'select',
activeShapeKind: 'rect',
selection: null,
zoom: 1,
saveStatus: 'idle',
annotationUpdatedAt: null,
defaultTextProps: {},
draftAnnotation: null,
isSignatureModalOpen: false,
pendingSignatureProps: null,
pendingImageRef: null,
activeShapeKind: 'rect',
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,
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
})),
setActiveTool: (tool) => set({ activeTool: tool }),
setSelection: (id) => set({ selection: id }),
setZoom: (zoom) => set({ zoom: Math.max(0.5, Math.min(3, zoom)) }),
setZoom: (zoom) => set({ zoom }),
setSaveStatus: (status) => set({ saveStatus: status }),
setDefaultTextProps: (props) =>
set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
setDraftAnnotation: (annotation) => set({ draftAnnotation: annotation }),
setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
setDraftAnnotation: (ann) => set({ draftAnnotation: ann }),
setIsSignatureModalOpen: (isOpen) => set({ isSignatureModalOpen: isOpen }),
setPendingSignatureProps: (props) => set({ pendingSignatureProps: props }),
setPendingImageRef: (image) => set({ pendingImageRef: image }),
setPendingImageRef: (imgRef) => set({ pendingImageRef: imgRef }),
setActiveShapeKind: (kind) => set({ activeShapeKind: kind }),
}));

View file

@ -1,162 +1,161 @@
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 { useRef, useState } from 'react';
import { useEditorStore } from '../store';
import { SignatureModal } from '../tools/SignatureModal';
import { api } from '../../../lib/api/client';
import { useEditorStore } from '../store';
import type { SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types';
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,
export function EditorToolbar() {
const {
activeTool,
setActiveTool,
saveStatus,
zoom,
setZoom,
isSignatureModalOpen,
setIsSignatureModalOpen,
setPendingSignatureProps,
setPendingImageRef,
documentId,
past,
future,
undo,
redo,
documentId
} = useEditorStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
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];
if (!file || !documentId) return;
setIsUploadingImage(true);
try {
const formData = new FormData();
formData.append('file', file);
const data = await api.upload<AssetUploadResponse>(`/documents/${documentId}/assets`, formData);
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.
setPendingImageRef({ ref: data.ref, width: 0, height: 0 });
setActiveTool('image');
} catch (reason) {
console.error('Failed to upload image', reason);
window.alert(reason instanceof Error ? reason.message : 'Failed to upload image.');
} catch (err) {
console.error(err);
alert('Failed to upload image');
} finally {
setIsUploadingImage(false);
// Reset input
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSignatureConfirm = (props: SignatureDrawProps | SignatureTypeProps) => {
const handleSignatureConfirm = (props: any) => {
setPendingSignatureProps(props);
setIsSignatureModalOpen(false);
setActiveTool('signature');
};
const isActive = (name: string) => activeTool === name ? active : inactive;
return (
<>
<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
<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
</button>
<button type="button" className={`${toolButton} ${isActive('text')}`} onClick={() => setActiveTool('text')} title="Add text">
<Type className="h-4 w-4" /> Text
<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>
<button type="button" className={`${toolButton} ${isActive('draw')}`} onClick={() => setActiveTool('draw')} title="Draw freehand">
<PenLine className="h-4 w-4" /> Draw
<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>
<button type="button" className={`${toolButton} ${isActive('highlight')}`} onClick={() => setActiveTool('highlight')} title="Highlight an area">
<Highlighter className="h-4 w-4" /> Highlight
<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>
<button type="button" className={`${toolButton} ${isActive('shape')}`} onClick={() => setActiveTool('shape')} title="Draw a shape">
<Square className="h-4 w-4" /> Shape
<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>
{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"
<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"
>
<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)} />
-
</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>
<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="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} ${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…'}
<div className="text-xs font-medium text-neutral-400 min-w-16 text-center">
{saveStatus === 'saving' && 'Saving...'}
{saveStatus === 'saved' && 'Saved'}
{saveStatus === 'error' && <span className="text-red-600">Save failed</span>}
</span>
{saveStatus === 'error' && <span className="text-red-500">Error saving</span>}
{saveStatus === 'idle' && ''}
</div>
</div>
{isSignatureModalOpen && (
<SignatureModal
<SignatureModal
onClose={() => setIsSignatureModalOpen(false)}
onConfirm={handleSignatureConfirm}
/>

View file

@ -1,6 +1,4 @@
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';
@ -19,7 +17,6 @@ import {
interface TextFormatToolbarProps {
annotationId: string;
viewportParams: ViewportParams;
canvas: Canvas;
}
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New'];
@ -27,7 +24,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, canvas }: TextFormatToolbarProps) {
export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatToolbarProps) {
const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore();
const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null);
@ -54,9 +51,11 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
const top = pt.y - 48; // 48px above
const left = pt.x;
const applyStyle = (styleName: string, value: unknown, globalPropName: keyof TextProps, globalValue?: unknown) => {
const activeObj = canvas.getActiveObject() as (fabric.Textbox & { id?: string; customHeight?: number }) | undefined;
if (activeObj) {
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();
if (activeObj && activeObj.id === annotationId) {
const isStructural = styleName === 'fontSize' || styleName === 'fontFamily';
@ -82,7 +81,7 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
activeObj.styles = {};
if (activeObj.hiddenTextarea) {
if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`;
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value);
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = value;
}
}
} else {
@ -101,7 +100,7 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
for (const line in activeObj.styles) {
for (const char in activeObj.styles[line]) {
if (activeObj.styles[line][char]) {
delete (activeObj.styles[line][char] as Record<string, unknown>)[styleName];
delete activeObj.styles[line][char][styleName];
}
}
}
@ -111,8 +110,8 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
// Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw!
activeObj.dirty = true;
if ('_forceClearCache' in activeObj) {
(activeObj as typeof activeObj & { _forceClearCache?: boolean })._forceClearCache = true;
if ((activeObj as any)._forceClearCache !== undefined) {
(activeObj as any)._forceClearCache = true;
}
// Remove manual height constraint so the box can grow with the new font size
@ -124,7 +123,7 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
}
const finalGlobalValue = globalValue !== undefined ? globalValue : value;
const newProps = { [globalPropName]: finalGlobalValue } as Partial<TextProps>;
const newProps = { [globalPropName]: finalGlobalValue } as any;
setDefaultTextProps(newProps);
// Always update store so the toolbar displays the new value
@ -155,9 +154,12 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
const handleDelete = () => {
if (isDraft) {
useEditorStore.getState().setDraftAnnotation(null);
const activeObj = canvas.getActiveObject();
if (activeObj && (activeObj as fabric.FabricObject & { id?: string }).id === annotationId) {
canvas.remove(activeObj);
const canvas = (window as any).__fabricCanvas as any;
if (canvas) {
const activeObj = canvas.getActiveObject();
if (activeObj && activeObj.id === annotationId) {
canvas.remove(activeObj);
}
}
} else {
deleteAnnotation(annotationId);
@ -218,7 +220,7 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text
<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, 'fontSize', s); setActiveDropdown(null); }}
onClick={() => { applyStyle('fontSize', s * viewportParams.scale * (viewportParams.dpr || 1), 'fontSize', s); setActiveDropdown(null); }}
>
{s}
</button>

View file

@ -6,12 +6,6 @@ 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',
@ -27,7 +21,7 @@ export const DrawTool: ToolHandler = {
canvas.isDrawingMode = false;
},
onPathCreated: (e: fabric.CanvasEvents['path:created'], _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
onPathCreated: (e: any, _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
const pathObj = e.path as fabric.Path;
pathObj.set({
@ -39,16 +33,27 @@ export const DrawTool: ToolHandler = {
padding: 5,
});
const annotatedPath = pathObj as FabricAnnotationObject;
annotatedPath.id = uuidv4();
annotatedPath.annotationType = 'draw';
(pathObj as any).id = uuidv4();
(pathObj as any).annotationType = 'draw';
// Keep the SVG path for faithful browser re-rendering and also store a
// canonical point list for the server export renderer.
// 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
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,
@ -56,36 +61,9 @@ 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: annotatedPath.id as string,
id: (pathObj as any).id,
page: pageNumber,
type: 'draw',
rect: pdfRect,
@ -103,7 +81,7 @@ export const DrawTool: ToolHandler = {
},
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'draw') return;
if (annotation.type !== 'draw') return null;
const drawAnn = annotation as DrawAnnotation;
const props = drawAnn.props as DrawProps;
const screenRect = pdfRectToScreen(drawAnn.rect, vp);
@ -125,8 +103,6 @@ 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!,
});
@ -135,8 +111,6 @@ 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',
@ -144,18 +118,17 @@ 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);
}

View file

@ -1,120 +0,0 @@
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);
},
};

View file

@ -6,12 +6,6 @@ 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',
@ -24,7 +18,7 @@ export const ImageTool: ToolHandler = {
canvas.defaultCursor = 'default';
},
onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
if (e.target) return;
const storeState = useEditorStore.getState();
@ -51,8 +45,8 @@ export const ImageTool: ToolHandler = {
img.set({
left: pointer.x,
top: pointer.y,
originX: 'left',
originY: 'top',
originX: 'center',
originY: 'center',
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
@ -61,16 +55,15 @@ export const ImageTool: ToolHandler = {
padding: 5,
});
const annotatedImage = img as FabricAnnotationObject;
annotatedImage.id = uuidv4();
annotatedImage.annotationType = 'image';
(img as any).id = uuidv4();
(img as any).annotationType = 'image';
const props: ImageProps = {
ref: pendingImage.ref,
naturalWidth: pendingImage.width,
naturalHeight: pendingImage.height
};
annotatedImage.annotationProps = props;
(img as any).annotationProps = props;
canvas.add(img);
canvas.setActiveObject(img);
@ -85,7 +78,7 @@ export const ImageTool: ToolHandler = {
}, vp);
const annotation: ImageAnnotation = {
id: annotatedImage.id as string,
id: (img as any).id,
page: pageNumber,
type: 'image',
rect: pdfRect,
@ -104,7 +97,7 @@ export const ImageTool: ToolHandler = {
},
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'image') return;
if (annotation.type !== 'image') return null;
const imgAnn = annotation as ImageAnnotation;
const screenRect = pdfRectToScreen(imgAnn.rect, vp);
@ -114,27 +107,24 @@ export const ImageTool: ToolHandler = {
img = await fabric.Image.fromURL(url);
} catch (e) {
console.error("Failed to load asset", e);
return;
return null;
}
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);
}

View file

@ -1,210 +0,0 @@
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);
},
};

View file

@ -17,10 +17,6 @@ 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');
@ -120,7 +116,7 @@ export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) {
const formData = new FormData();
formData.append('file', blob, 'signature.png');
const data = await api.upload<AssetUploadResponse>(`/documents/${documentId}/assets`, formData);
const data = await api.upload<any>(`/documents/${documentId}/assets`, formData);
onConfirm({
mode: 'draw',

View file

@ -6,18 +6,11 @@ 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: SignatureProps) => {
const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: any) => {
isCreatingPreview = true;
const storeState = useEditorStore.getState();
if (props.mode === 'draw') {
@ -30,13 +23,11 @@ const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: S
}
img.set({ originX: 'center', originY: 'center', opacity: 0.5, evented: false });
previewObj = img;
} catch {
previewObj = null;
}
} catch (e) {}
} else {
previewObj = new fabric.Text(props.text, {
fontFamily: props.fontFamily,
fontSize: 48 * vp.scale,
fontSize: 48 * vp.scale * (vp.dpr || 1),
fill: props.color,
originX: 'center',
originY: 'center',
@ -70,7 +61,7 @@ export const SignatureTool: ToolHandler = {
isCreatingPreview = false;
},
onPointerMove: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams) => {
onPointerMove: (e: any, canvas: fabric.Canvas, vp: ViewportParams, _pageNumber: number) => {
const props = useEditorStore.getState().pendingSignatureProps;
if (!props) return;
@ -95,7 +86,7 @@ export const SignatureTool: ToolHandler = {
}
},
onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
if (e.target && e.target !== previewObj) return;
const storeState = useEditorStore.getState();
@ -129,7 +120,7 @@ export const SignatureTool: ToolHandler = {
originY: 'center',
});
// We set id on the object so it can be identified
(img as FabricSignatureObject).id = uuidv4();
(img as any).id = uuidv4();
fabricObj = img;
} catch (err) {
console.error("Failed to load signature image", err);
@ -141,12 +132,12 @@ export const SignatureTool: ToolHandler = {
left: pointer.x,
top: pointer.y,
fontFamily: typeProps.fontFamily,
fontSize: 48 * vp.scale,
fontSize: 48 * vp.scale * (vp.dpr || 1),
fill: typeProps.color,
originX: 'center',
originY: 'center',
});
(textObj as FabricSignatureObject).id = uuidv4();
(textObj as any).id = uuidv4();
fabricObj = textObj;
}
@ -159,9 +150,8 @@ export const SignatureTool: ToolHandler = {
padding: 5,
});
const annotatedObject = fabricObj as FabricSignatureObject;
annotatedObject.annotationType = 'signature';
annotatedObject.annotationProps = props;
(fabricObj as any).annotationType = 'signature';
(fabricObj as any).annotationProps = props;
canvas.add(fabricObj);
canvas.setActiveObject(fabricObj);
@ -177,7 +167,7 @@ export const SignatureTool: ToolHandler = {
}, vp);
const annotation: SignatureAnnotation = {
id: annotatedObject.id as string,
id: (fabricObj as any).id,
page: pageNumber,
type: 'signature',
rect: pdfRect,
@ -196,7 +186,7 @@ export const SignatureTool: ToolHandler = {
},
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'signature') return;
if (annotation.type !== 'signature') return null;
const sigAnn = annotation as SignatureAnnotation;
const screenRect = pdfRectToScreen(sigAnn.rect, vp);
@ -209,23 +199,19 @@ 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;
return null;
}
} 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,
});
@ -236,18 +222,17 @@ 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);
}

View file

@ -3,14 +3,9 @@ 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 { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
import { screenToPdf, pdfRectToScreen } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords';
type FabricTextObject = fabric.Textbox & {
id?: string;
customHeight?: number;
};
export const TextTool: ToolHandler = {
name: 'text',
@ -23,7 +18,7 @@ export const TextTool: ToolHandler = {
canvas.defaultCursor = 'default';
},
onPointerDown: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
onPointerDown: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
// If we clicked on an existing object, don't create a new one
if (e.target) return;
@ -44,7 +39,7 @@ export const TextTool: ToolHandler = {
top: pointer.y,
width: 150 * vp.scale,
fontFamily: fontFamily,
fontSize: fontSize * vp.scale,
fontSize: fontSize * vp.scale * (vp.dpr || 1),
fontWeight: isBold ? 'bold' : 'normal',
fontStyle: isItalic ? 'italic' : 'normal',
fill: color,
@ -60,13 +55,12 @@ export const TextTool: ToolHandler = {
});
// Allow manual height control
const annotatedTextbox = textbox as FabricTextObject;
annotatedTextbox.customHeight = textbox.height;
(textbox as any).customHeight = textbox.height;
const originalInitDimensions = textbox.initDimensions.bind(textbox);
textbox.initDimensions = function() {
originalInitDimensions();
if ((this as FabricTextObject).customHeight !== undefined) {
this.height = (this as FabricTextObject).customHeight as number;
if ((this as any).customHeight !== undefined) {
this.height = (this as any).customHeight;
}
};
@ -100,7 +94,7 @@ export const TextTool: ToolHandler = {
const w = textbox.width! * textbox.scaleX!;
const h = textbox.height! * textbox.scaleY!;
annotatedTextbox.customHeight = h;
(textbox as any).customHeight = h;
textbox.set({
width: w,
@ -112,27 +106,24 @@ export const TextTool: ToolHandler = {
});
const newId = uuidv4();
annotatedTextbox.id = newId;
(textbox as any).id = newId;
canvas.add(textbox);
canvas.setActiveObject(textbox);
textbox.enterEditing();
// Add to store immediately so the toolbar shows up instantly
const initialBounds = textbox.getBoundingRect();
const pt = screenToPdf({ x: textbox.left!, y: textbox.top! }, vp);
const newAnn: TextAnnotation = {
id: newId,
page: pageNumber,
type: 'text',
rect: screenRectToPdf(
{
x: initialBounds.left,
y: initialBounds.top,
width: initialBounds.width,
height: initialBounds.height,
},
vp,
),
rect: {
x: pt.x,
y: pt.y,
width: textbox.width! / (vp.scale * (vp.dpr || 1)),
height: textbox.height! / (vp.scale * (vp.dpr || 1))
},
rotation: 0,
z: 0,
createdAt: new Date().toISOString(),
@ -152,49 +143,41 @@ 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;
}
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(),
};
// 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;
// Save inline styles if any exist
if (textbox.styles && Object.keys(textbox.styles).length > 0) {
nextAnnotation.props.styles = JSON.parse(JSON.stringify(textbox.styles));
if ((textbox as any).styles && Object.keys((textbox as any).styles).length > 0) {
currentAnn.props.styles = JSON.parse(JSON.stringify((textbox as any).styles));
}
state.setDraftAnnotation(null);
state.addAnnotation(nextAnnotation);
useEditorStore.getState().addAnnotation(currentAnn);
});
},
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;
const boxHeight = textAnn.rect.height * vp.scale * (vp.dpr || 1);
const textbox = new fabric.Textbox(textAnn.props.text, {
left: pt.x,
top: pt.y,
width: textAnn.rect.width * vp.scale,
width: textAnn.rect.width * vp.scale * (vp.dpr || 1),
height: boxHeight,
fontFamily: textAnn.props.fontFamily,
fontSize: textAnn.props.fontSize * vp.scale,
fontSize: textAnn.props.fontSize * vp.scale * (vp.dpr || 1),
fontWeight: textAnn.props.bold ? 'bold' : 'normal',
fontStyle: textAnn.props.italic ? 'italic' : 'normal',
fill: textAnn.props.color,
@ -211,13 +194,12 @@ export const TextTool: ToolHandler = {
});
// Allow manual height control
const annotatedTextbox = textbox as FabricTextObject;
annotatedTextbox.customHeight = boxHeight;
(textbox as any).customHeight = boxHeight;
const originalInitDimensions = textbox.initDimensions.bind(textbox);
textbox.initDimensions = function() {
originalInitDimensions();
if ((this as FabricTextObject).customHeight !== undefined) {
this.height = (this as FabricTextObject).customHeight as number;
if ((this as any).customHeight !== undefined) {
this.height = (this as any).customHeight;
}
};
@ -250,7 +232,7 @@ export const TextTool: ToolHandler = {
const w = textbox.width! * textbox.scaleX!;
const h = textbox.height! * textbox.scaleY!;
annotatedTextbox.customHeight = h;
(textbox as any).customHeight = h;
textbox.set({
width: w,
@ -266,16 +248,16 @@ export const TextTool: ToolHandler = {
const currentAnn = useEditorStore.getState().annotations.find(a => a.id === annotation.id) as TextAnnotation | undefined;
if (!currentAnn) return;
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>;
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));
}
useEditorStore.getState().updateAnnotation(annotation.id, {
props: { ...currentAnn.props, ...updates }
});
});
annotatedTextbox.id = annotation.id;
(textbox as any).id = annotation.id;
canvas.add(textbox);
}
};

View file

@ -3,16 +3,12 @@ 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({

View file

@ -1,135 +1,75 @@
import { useCallback, useEffect, useRef } from 'react';
import { api, ApiRequestError } from '../../lib/api/client';
import type { Annotation } from '../../lib/annotations/types';
import { useEffect, useRef } from 'react';
import { useEditorStore } from './store';
import type { Annotation } from '../../lib/annotations/types';
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);
export function useAutosave() {
const { documentId, annotations, setSaveStatus, setAnnotations } = useEditorStore();
const timeoutRef = useRef<number | null>(null);
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);
const isFirstLoad = useRef(true);
// Initial load
useEffect(() => {
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]);
if (!documentId) return;
useEffect(() => {
if (!documentId || storeDocumentId !== documentId) return;
let active = true;
setSaveStatus('loading');
async function load() {
try {
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');
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;
}
} catch (err) {
console.error('Error loading annotations:', err);
if (active) setSaveStatus('error');
}
}
void load();
return () => {
active = false;
};
}, [documentId, setAnnotationUpdatedAt, setAnnotations, setSaveStatus, storeDocumentId]);
load();
return () => { active = false; };
}, [documentId, setAnnotations, setSaveStatus]);
// Debounced save
useEffect(() => {
// Don't save on the initial load!
if (isFirstLoad.current) return;
if (!documentId) return;
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');
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 (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
})
});
if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) {
setAnnotationUpdatedAt(response.updatedAt);
if (!res.ok) throw new Error('Failed to save');
setSaveStatus('saved');
}
return true;
} catch (error) {
if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) {
console.error('Error saving annotations:', error);
} catch (err) {
console.error('Error saving annotations:', err);
setSaveStatus('error');
}
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);
}, 500); // 500ms debounce
return () => {
saveGeneration.current += 1;
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
};
}, [annotations, documentId, flush, setSaveStatus, storeDocumentId]);
useEffect(() => () => {
saveGeneration.current += 1;
if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
}, []);
return { flush };
}, [annotations, documentId, setSaveStatus]);
}

View file

@ -1,236 +0,0 @@
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>
);
}

View file

@ -6,7 +6,7 @@ import { formatDistanceToNow } from 'date-fns'
import { ContextMenu } from './ContextMenu'
export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
const { documents, selectedIds, isLoading, error, toggleSelection, deleteDocument, restoreDocument, updateDocument } = useLibrary()
const { documents, selectedIds, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary()
const navigate = useNavigate()
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null)
@ -61,16 +61,6 @@ 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">
@ -137,13 +127,9 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
}
}
}}
onDelete={() => {
if (window.confirm('Move this document to Trash?')) void deleteDocument(contextMenu.docId)
}}
onDelete={() => deleteDocument(contextMenu.docId)}
onRestore={() => restoreDocument(contextMenu.docId)}
onHardDelete={() => {
if (window.confirm('Delete this document permanently? This cannot be undone.')) void deleteDocument(contextMenu.docId, true)
}}
onHardDelete={() => deleteDocument(contextMenu.docId, true)}
/>
)}
</>

View file

@ -94,9 +94,7 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar
Restore
</button>
<button
onClick={() => {
if (window.confirm('Delete the selected documents permanently? This cannot be undone.')) void bulkDelete(true)
}}
onClick={() => 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
@ -125,9 +123,7 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar
{currentTab === 'trash' && (
<button
onClick={() => {
if (window.confirm('Empty Trash permanently? This cannot be undone.')) void emptyTrash()
}}
onClick={() => 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

View file

@ -1,74 +1,88 @@
import { useRef, useState, type ReactNode } from 'react';
import { useLibrary } from './useLibrary';
import { UploadPickerContext, useUploadPicker } from './upload-context';
import { useRef, useState } from 'react'
import { useLibrary } from './useLibrary'
export function UploadTrigger({ children, className }: { children: ReactNode; className: string }) {
const openPicker = useUploadPicker();
return <button type="button" onClick={openPicker} className={className}>{children}</button>;
}
export const UploadArea = ({ children }: { children: React.ReactNode }) => {
const { uploadDocument } = useLibrary()
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
export const UploadArea = ({ children }: { children: ReactNode }) => {
const { uploadDocument } = useLibrary();
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
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 = ''
}
}
const processFiles = async (files: FileList) => {
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);
// 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
}
}
}
};
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 (
<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 />
{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
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>
)}
{children}
</div>
</UploadPickerContext.Provider>
);
};
{children}
</div>
)
}

View file

@ -1,9 +0,0 @@
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;
}

View file

@ -28,17 +28,6 @@ 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,
@ -67,8 +56,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: unknown) {
set({ error: errorMessage(err), isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
}
},
@ -77,8 +66,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: unknown) {
set({ error: errorMessage(err), isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
}
},
@ -92,8 +81,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
isLoading: false
}))
return newDoc
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
@ -108,8 +97,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
isLoading: false
}))
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
@ -124,8 +113,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
isLoading: false
}))
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
@ -149,8 +138,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
selectedIds: new Set(),
isLoading: false
}))
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
@ -168,8 +157,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
selectedIds: new Set(),
isLoading: false
}))
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
@ -184,8 +173,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
selectedIds: new Set(),
isLoading: false
})
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
},
@ -198,8 +187,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
documents: state.documents.map(d => d.id === id ? updatedDoc : d),
isLoading: false
}))
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
throw err
}
}

View file

@ -6,11 +6,6 @@
@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";

View file

@ -17,19 +17,15 @@ export interface ToolHandler {
onDeactivate?: (canvas: Canvas) => void;
/** Called when the user presses down on the canvas. */
onPointerDown?: (e: fabric.TPointerEventInfo, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void | Promise<void>;
onPointerDown?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
/** Called when the user drags the pointer. */
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>;
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;
// Fabric to/from Annotation serialization
renderToFabric?: (
annotation: Annotation,
canvas: Canvas,
viewportParams: ViewportParams,
) => void | Promise<void>;
renderToFabric?: (annotation: Annotation, canvas: Canvas, viewportParams: ViewportParams) => void;
}
const registry = new Map<string, ToolHandler>();

View file

@ -1,125 +1,28 @@
/**
* 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.
*/
import type { components } from '../../types/api';
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export type TextProps = components['schemas']['TextProps'] & { highlightColor?: string | null, styles?: Record<string, any> | null };
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';
export type TextAnnotation = Omit<components['schemas']['TextAnnotation'], 'props'> & {
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 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 =
export type Annotation =
| TextAnnotation
| DrawAnnotation
| SignatureAnnotation
| ImageAnnotation
| HighlightAnnotation
| ShapeAnnotation
| UnknownAnnotation;
| 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'];

View file

@ -30,21 +30,6 @@ 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.
*/
@ -90,19 +75,23 @@ async function apiFetch<T>(
}
// Parse response
let data: unknown = {}
let data: any = {}
try {
const text = await response.text()
if (text) {
data = JSON.parse(text)
}
} catch {
} catch (err) {
// If it's not JSON, we'll just fall back to the empty object
}
// Handle error responses
if (!response.ok) {
throw new ApiRequestError(response.status, toApiError(data, response.statusText))
const error: ApiError = {
code: data.error?.code ?? 'unknown',
message: data.error?.message ?? data.detail ?? response.statusText,
}
throw new ApiRequestError(response.status, error)
}
return data as T
@ -169,15 +158,17 @@ export const api = {
}
if (!response.ok) {
let data: unknown = {}
let data: any = {}
try {
const text = await response.text()
if (text) data = JSON.parse(text)
} catch {
data = {}
} catch (err) {}
const error: ApiError = {
code: data.error?.code ?? 'unknown',
message: data.error?.message ?? data.detail ?? response.statusText,
}
throw new ApiRequestError(response.status, toApiError(data, response.statusText))
throw new ApiRequestError(response.status, error)
}
return response.blob()

View file

@ -92,16 +92,4 @@ 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);
});
});

View file

@ -5,18 +5,19 @@ 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; // Backing-store density; never part of CSS geometry
dpr?: number; // Device Pixel Ratio (defaults to 1)
}
function getEffectiveScale(vp: ViewportParams): number {
return vp.scale * (vp.dpr || 1);
}
export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
// Screen points are CSS pixels. Device-pixel-ratio only controls the PDF.js
// backing canvas; including it here would make Fabric objects overflow the
// CSS overlay on retina displays.
const s = vp.scale;
const s = getEffectiveScale(vp);
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
const { x, y } = p;
@ -32,7 +33,7 @@ export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
}
export function screenToPdf(p: ScreenPoint, vp: ViewportParams): PdfPoint {
const s = vp.scale;
const s = getEffectiveScale(vp);
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
const sx = p.x / s;
const sy = p.y / s;

View file

@ -1,113 +1,61 @@
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;
}
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'
/**
* Editor page PDF canvas workspace.
*/
export function EditorPage() {
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);
const { id } = useParams<{ id: string }>()
const { setDocumentId } = useEditorStore()
useAutosave()
useEffect(() => {
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]);
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);
if (id) {
setDocumentId(id)
}
}, [flush, id, title]);
}, [id, setDocumentId])
if (!id) return <div className="p-8 text-center text-red-600">Invalid document ID.</div>;
if (!id) return <div>Invalid document ID</div>
return (
<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">
<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"
>
Back
</Link>
<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>
<span className="text-sm font-medium text-neutral-700">
Document {id}
</span>
</div>
<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'}
<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
</button>
</div>
</header>
<main className="relative flex-1 overflow-auto">
<EditorToolbar onExport={() => void handleExport()} onOpenVersions={() => setIsVersionPanelOpen(true)} isExporting={isExporting} />
{/* Main workspace area */}
<div className="flex-1 overflow-auto relative">
<EditorToolbar />
<PdfDocument documentId={id} />
</main>
{isVersionPanelOpen && <VersionPanel documentId={id} onClose={() => setIsVersionPanelOpen(false)} />}
</div>
</div>
);
)
}

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { LibraryHeader } from '../features/library/LibraryHeader'
import { UploadArea, UploadTrigger } from '../features/library/UploadArea'
import { UploadArea } from '../features/library/UploadArea'
import { DocumentGrid } from '../features/library/DocumentGrid'
import { useLibrary } from '../features/library/useLibrary'
@ -38,12 +38,26 @@ 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>
<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">
<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">
<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>
</UploadTrigger>
<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>
</div>
{/* Visual Graphic */}

View file

@ -272,46 +272,6 @@ 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;
@ -330,79 +290,6 @@ 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;
@ -423,6 +310,28 @@ 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 {
@ -430,9 +339,7 @@ export interface components {
/** AnnotationStateResponse */
AnnotationStateResponse: {
/** Data */
data: {
[key: string]: unknown;
}[];
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
/**
* Updatedat
* Format: date-time
@ -442,9 +349,7 @@ export interface components {
/** AnnotationStateUpdateRequest */
AnnotationStateUpdateRequest: {
/** Data */
data: {
[key: string]: unknown;
}[];
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
/** Baseupdatedat */
baseUpdatedAt?: string | null;
};
@ -463,11 +368,6 @@ 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 */
@ -525,31 +425,402 @@ export interface components {
/** In Trash */
in_trash?: boolean | null;
};
/** ExportRequest */
ExportRequest: {
/** Versionid */
versionId?: string | null;
/** DrawAnnotation */
DrawAnnotation: {
/**
* Flatten
* @default true
* Id
* Format: uuid
*/
flatten: boolean;
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;
};
/** 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 */
@ -563,58 +834,20 @@ export interface components {
/** Context */
ctx?: Record<string, never>;
};
/** 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;
/** VerifyCoordsRequest */
VerifyCoordsRequest: {
/** Document Id */
document_id: string;
/** Page */
page: number;
/** X */
x: number;
/** Y */
y: number;
/** Width */
width: number;
/** Height */
height: number;
};
};
responses: never;
@ -819,7 +1052,7 @@ export interface operations {
};
responses: {
/** @description Successful Response */
201: {
200: {
headers: {
[name: string]: unknown;
};
@ -1148,75 +1381,6 @@ 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;
@ -1283,13 +1447,11 @@ export interface operations {
};
};
};
list_versions_api_v1_documents__document_id__versions_get: {
health_check_api_v1_health_get: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
};
path?: never;
cookie?: never;
};
requestBody?: never;
@ -1300,161 +1462,23 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionListResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
"application/json": {
[key: string]: string;
};
};
};
};
};
create_version_api_v1_documents__document_id__versions_post: {
verify_coords_api_v1_debug_verify_coords_post: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"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"];
"application/json": components["schemas"]["VerifyCoordsRequest"];
};
};
responses: {
@ -1478,26 +1502,4 @@ 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;
};
};
};
};
};
}

View file

@ -272,46 +272,6 @@ 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;
@ -330,79 +290,6 @@ 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;
@ -423,6 +310,28 @@ 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 {
@ -430,9 +339,7 @@ export interface components {
/** AnnotationStateResponse */
AnnotationStateResponse: {
/** Data */
data: {
[key: string]: unknown;
}[];
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
/**
* Updatedat
* Format: date-time
@ -442,9 +349,7 @@ export interface components {
/** AnnotationStateUpdateRequest */
AnnotationStateUpdateRequest: {
/** Data */
data: {
[key: string]: unknown;
}[];
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
/** Baseupdatedat */
baseUpdatedAt?: string | null;
};
@ -463,11 +368,6 @@ 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 */
@ -525,31 +425,396 @@ export interface components {
/** In Trash */
in_trash?: boolean | null;
};
/** ExportRequest */
ExportRequest: {
/** Versionid */
versionId?: string | null;
/** DrawAnnotation */
DrawAnnotation: {
/**
* Flatten
* @default true
* Id
* Format: uuid
*/
flatten: boolean;
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;
};
/** 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 */
@ -563,58 +828,20 @@ export interface components {
/** Context */
ctx?: Record<string, never>;
};
/** 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;
/** VerifyCoordsRequest */
VerifyCoordsRequest: {
/** Document Id */
document_id: string;
/** Page */
page: number;
/** X */
x: number;
/** Y */
y: number;
/** Width */
width: number;
/** Height */
height: number;
};
};
responses: never;
@ -819,7 +1046,7 @@ export interface operations {
};
responses: {
/** @description Successful Response */
201: {
200: {
headers: {
[name: string]: unknown;
};
@ -1148,75 +1375,6 @@ 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;
@ -1283,13 +1441,11 @@ export interface operations {
};
};
};
list_versions_api_v1_documents__document_id__versions_get: {
health_check_api_v1_health_get: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
};
path?: never;
cookie?: never;
};
requestBody?: never;
@ -1300,161 +1456,23 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionListResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
"application/json": {
[key: string]: string;
};
};
};
};
};
create_version_api_v1_documents__document_id__versions_post: {
verify_coords_api_v1_debug_verify_coords_post: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"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"];
"application/json": components["schemas"]["VerifyCoordsRequest"];
};
};
responses: {
@ -1478,26 +1496,4 @@ 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;
};
};
};
};
};
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -104,9 +104,7 @@
"kind": { "type": "string", "enum": ["rect", "ellipse", "line", "arrow"] },
"strokeColor": { "type": "string", "default": "#000000" },
"fillColor": { "type": "string", "default": "transparent" },
"strokeWidth": { "type": "number", "default": 2 },
"start": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
"end": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
"strokeWidth": { "type": "number", "default": 2 }
},
"required": ["kind"]
}