luna-v1-rescue #1

Merged
Elijah merged 2 commits from luna-v1-rescue into main 2026-08-15 11:08:45 -07:00
93 changed files with 6470 additions and 4039 deletions

20
.dockerignore Normal file
View file

@ -0,0 +1,20 @@
.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. # Secret key for signing session cookies.
# Generate a strong random value: python -c "import secrets; print(secrets.token_urlsafe(32))" # Generate a strong random value: python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=change-me-in-production SECRET_KEY=replace-with-a-long-random-secret
# -------------------------------------------- # --------------------------------------------
# Upload Limits # Upload Limits
@ -40,9 +40,13 @@ COOKIE_SECURE=false
# -------------------------------------------- # --------------------------------------------
# Networking # Networking
# -------------------------------------------- # --------------------------------------------
# Port the frontend container publishes. Your outer reverse proxy targets this. # Port the PaperJet container publishes. Your outer reverse proxy targets this.
HTTP_PORT=4982 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 # Debug
# -------------------------------------------- # --------------------------------------------

View file

@ -1,25 +0,0 @@
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,3 +69,73 @@ jobs:
- name: Test (vitest) - name: Test (vitest)
working-directory: frontend working-directory: frontend
run: npx vitest run 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 }}"

55
Dockerfile Normal file
View file

@ -0,0 +1,55 @@
# 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 # Self-Hosted PDF Editor — Implementation Plan
**Project codename:** `paperjet` (rename freely) **Project codename:** `paperjet` (rename freely)
**Document version:** 1.1 **Document version:** 1.2
**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. **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.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**. > **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.
--- ---
@ -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. 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's own containers 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 image are permitted.
### Non-goals (v1) ### Non-goals (v1)
- Multi-user collaboration / real-time co-editing - 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 | | ORM | SQLAlchemy 2.x | With Alembic for migrations |
| Database | SQLite (WAL mode) | Single-user scale; embedded, no extra container | | Database | SQLite (WAL mode) | Single-user scale; embedded, no extra container |
| Password hashing | Argon2id (`argon2-cffi`) | | | Password hashing | Argon2id (`argon2-cffi`) | |
| Web server (frontend) | nginx | Serves the static React build; also the app-internal entry | | Web server | nginx | Serves the static React build and proxies /api to loopback uvicorn |
| ASGI server (backend) | uvicorn | Behind nginx | | ASGI server | uvicorn | Runs on loopback inside the same application image |
| Orchestration | Docker Compose | | | Orchestration | Docker Compose | |
| External programs / containers / APIs | **None** | Hard requirement | | External programs / containers / APIs | **None** | Hard requirement |
@ -98,13 +98,11 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
┌─────────────▼─────────────────────────────┐ ┌─────────────▼─────────────────────────────┐
│ Docker Compose │ │ Docker Compose │
│ │ │ │
│ ┌──────────────┐ ┌────────────────┐ │ │ ┌──────────────────────────────────────┐ │
│ │ frontend │ │ backend │ │ │ │ paperjet image │ │
│ │ nginx │─────▶│ FastAPI │ │ │ │ nginx :80 ── /api ──▶ uvicorn :8000 │ │
│ │ serves SPA │ /api │ uvicorn │ │ │ │ React SPA FastAPI + PyMuPDF │ │
│ │ proxies /api│ │ PyMuPDF │ │ │ └───────────────────┬──────────────────┘ │
│ └──────────────┘ │ SQLAlchemy │ │
│ └───────┬────────┘ │
│ │ │ │ │ │
│ ┌──────────▼────────┐ │ │ ┌──────────▼────────┐ │
│ │ volumes │ │ │ │ volumes │ │
@ -115,26 +113,29 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
└────────────────────────────────────────────┘ └────────────────────────────────────────────┘
``` ```
- 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 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 `frontend` container's published port. - The user's outer reverse proxy points at the published port of the `paperjet` container.
--- ---
## 5. Repository Structure ## 5. Repository Structure
A single monorepo. Two deployable images. A single monorepo with one deployable runtime image. The Dockerfile uses a
Node build stage and a Python/nginx runtime stage.
``` ```
paperjet/ paperjet/
├── README.md ├── README.md
├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first ├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first
├── docker-compose.yml ├── docker-compose.yml
├── Dockerfile
├── docker-entrypoint.sh
├── .dockerignore
├── .env.example ├── .env.example
├── .github/workflows/ci.yml # lint + typecheck + test on push ├── .forgejo/workflows/ci.yml # lint, tests, image build, and publish
├── frontend/ ├── frontend/
│ ├── Dockerfile # multi-stage: build SPA, serve via nginx │ ├── nginx.conf # SPA fallback + loopback /api proxy
│ ├── nginx.conf # SPA fallback + /api proxy
│ ├── package.json │ ├── package.json
│ ├── tsconfig.json # strict: true │ ├── tsconfig.json # strict: true
│ ├── vite.config.ts │ ├── vite.config.ts
@ -163,7 +164,6 @@ paperjet/
│ └── types/ # generated from backend OpenAPI │ └── types/ # generated from backend OpenAPI
├── backend/ ├── backend/
│ ├── Dockerfile
│ ├── pyproject.toml │ ├── pyproject.toml
│ ├── alembic/ # migrations │ ├── alembic/ # migrations
│ └── app/ │ └── 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. - The registry is a dict `{type: handler}`; adding a type adds a handler, nothing else.
- Missing handler → log + skip, never crash (§7.3). - Missing handler → log + skip, never crash (§7.3).
- Export accepts an optional `versionId` to export a historical version instead of the working state. - Export accepts an optional `versionId` to export a historical version instead of the working state.
- 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. - 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.
### 9.4 Assets (binary annotation payloads) ### 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 }`. - `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 ## 13. Docker Compose & Deployment
```yaml ```yaml
# docker-compose.yml (illustrative; agents finalize) # docker-compose.yml
services: services:
backend: paperjet:
build: ./backend image: ${PAPERJET_IMAGE:-paperjet:local}
build:
context: .
dockerfile: Dockerfile
environment: environment:
- SECRET_KEY=${SECRET_KEY} PAPERJET_SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting PaperJet}
- MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200} PAPERJET_MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-200}
- TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30} PAPERJET_TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
- AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30} PAPERJET_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) 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}
volumes: volumes:
- pdf_storage:/data/pdfs - pdf_storage:/data/pdfs
- thumbnails:/data/thumbnails - thumbnails:/data/thumbnails
- db:/data/db - db:/data/db
expose:
- "8000"
restart: unless-stopped
frontend:
build: ./frontend
depends_on: [backend]
ports: ports:
- "${HTTP_PORT:-8080}:80" # user's outer reverse proxy targets this - "${HTTP_PORT:-4982}:80"
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@ -530,10 +530,10 @@ volumes:
db: db:
``` ```
- `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. - `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.
- 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. - 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.
- Backend Dockerfile installs Python deps and the bundled fonts; PyMuPDF comes from its wheel (no system packages, no external programs). - The root Dockerfile builds the SPA with Node, installs production Python dependencies and bundled fonts, and copies both into one nginx/Python runtime image.
- Frontend Dockerfile is multi-stage: Node builds the SPA, the result is copied into an nginx image. - docker-entrypoint.sh supervises nginx and loopback uvicorn so a failed child process stops the container cleanly.
- All persistent state lives in named volumes mountable on the user's Unraid array. Document the volume → host-path mapping for backups. - 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). 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. 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`. 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 existing two images 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 application image 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. 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. 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. 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. Sequenced so each phase is independently testable and the riskiest correctness work (coordinates, export) is validated early.
**Phase 0 — Scaffolding** **Phase 0 — Scaffolding**
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. 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.
**Phase 1 — Auth & Library core** **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. 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.1. Amendments are made to this document first, then to code.* *End of plan v1.2. Amendments are made to this document first, then to code.*

86
README.md Normal file
View file

@ -0,0 +1,86 @@
# 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.

View file

@ -1,25 +0,0 @@
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 logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from alembic import context
from app.db import Base from app.db import Base
from app.models import * # noqa: F401, F403 — ensure all models are imported from app.models import * # noqa: F403 — ensure all models are imported
config = context.config config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:

View file

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

View file

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

View file

@ -1,56 +1,99 @@
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException """Authenticated binary assets used by annotations."""
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
import uuid import uuid
import shutil
from pathlib import Path from pathlib import Path
from app.db import get_db from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from app.auth.dependencies import get_current_user 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.config import settings
from app.db import get_db
from app.models.document import Document
router = APIRouter(prefix="/documents", tags=["Assets"]) 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
@router.post("/{id}/assets") 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)])
async def upload_asset( async def upload_asset(
id: str, id: str,
file: UploadFile = File(...), file: UploadFile = File(...),
user_id: int = Depends(get_current_user), user_id: int = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db),
): ) -> dict[str, str]:
"""Upload a binary asset (like an image or signature) for a document.""" """Upload a PNG, JPEG, GIF, or WebP annotation asset."""
# In a real app we'd verify the user owns the document here document = db.scalar(select(Document).where(Document.id == id))
# For this single-user app, we trust the ID if not document:
raise HTTPException(status_code=404, detail="Document not found")
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()) ref = str(uuid.uuid4())
filepath = get_asset_path(id, ref) filepath = get_asset_path(id, ref, create=True)
total_bytes = 0
try: try:
with open(filepath, "wb") as f: with filepath.open("wb") as destination:
shutil.copyfileobj(file.file, f) while chunk := file.file.read(_ASSET_CHUNK_SIZE):
except Exception as e: total_bytes += len(chunk)
raise HTTPException(status_code=500, detail=f"Failed to save asset: {e}") 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}"}
return {
"ref": ref,
"url": f"/api/v1/documents/{id}/assets/{ref}"
}
@router.get("/{id}/assets/{ref}") @router.get("/{id}/assets/{ref}")
async def get_asset( async def get_asset(
id: str, id: str,
ref: str, ref: str,
): db: Session = Depends(get_db),
"""Serve a binary asset.""" 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")
filepath = get_asset_path(id, ref) filepath = get_asset_path(id, ref)
if not filepath.exists() or not filepath.is_file(): if not filepath.exists() or not filepath.is_file():
raise HTTPException(status_code=404, detail="Asset not found") raise HTTPException(status_code=404, detail="Asset not found")

View file

@ -28,8 +28,8 @@ async def verify_coords(req: VerifyCoordsRequest):
try: try:
doc = pymupdf.open(filepath) doc = pymupdf.open(filepath)
except Exception as e: except Exception as error:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(error)) from error
if req.page < 0 or req.page >= len(doc): if req.page < 0 or req.page >= len(doc):
doc.close() doc.close()

View file

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

View file

@ -0,0 +1,94 @@
"""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

@ -0,0 +1,203 @@
"""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)

Binary file not shown.

View file

@ -0,0 +1,93 @@
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.

Binary file not shown.

Binary file not shown.

View file

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

View file

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

View file

@ -2,6 +2,7 @@
import time import time
from collections import defaultdict from collections import defaultdict
from fastapi import HTTPException from fastapi import HTTPException
from app.config import settings from app.config import settings

View file

@ -1,11 +1,13 @@
"""Session token generation and validation.""" """Session token generation and validation."""
from typing import Any from typing import Any
from fastapi import Response from fastapi import Response
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import settings from app.config import settings
def get_serializer() -> URLSafeTimedSerializer: def get_serializer() -> URLSafeTimedSerializer:
"""Return a configured URLSafeTimedSerializer.""" """Return a configured URLSafeTimedSerializer."""
return URLSafeTimedSerializer(settings.SECRET_KEY) return URLSafeTimedSerializer(settings.SECRET_KEY)

View file

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

View file

@ -4,11 +4,10 @@ Database engine, session management, and WAL mode setup.
SQLite with WAL mode for single-user concurrent read/write safety. SQLite with WAL mode for single-user concurrent read/write safety.
""" """
from collections.abc import AsyncGenerator from collections.abc import Generator
from contextlib import asynccontextmanager
from typing import Any from typing import Any
from sqlalchemy import event, create_engine, Engine from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings from app.config import settings
@ -16,6 +15,7 @@ from app.config import settings
class Base(DeclarativeBase): class Base(DeclarativeBase):
"""SQLAlchemy declarative base for all models.""" """SQLAlchemy declarative base for all models."""
pass pass
@ -49,10 +49,10 @@ engine = create_db_engine()
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False) SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
def get_db() -> Session: def get_db() -> Generator[Session, None, None]:
"""FastAPI dependency that yields a database session.""" """FastAPI dependency that yields a database session."""
db = SessionLocal() db = SessionLocal()
try: try:
yield db # type: ignore[misc] yield db
finally: finally:
db.close() 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. and ensures required directories exist on startup.
""" """
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any from typing import Any
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@ -14,7 +14,8 @@ from fastapi.responses import JSONResponse
from app.api.v1 import router as v1_router from app.api.v1 import router as v1_router
from app.config import settings from app.config import settings
from app.db import Base, engine from app.db import Base, SessionLocal, engine
from app.services.trash_sweep import prune_auto_versions, sweep_trash
@asynccontextmanager @asynccontextmanager
@ -29,6 +30,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
settings.THUMBNAILS_PATH.mkdir(parents=True, exist_ok=True) settings.THUMBNAILS_PATH.mkdir(parents=True, exist_ok=True)
settings.DATABASE_PATH.parent.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 yield
# Shutdown: dispose of the engine # Shutdown: dispose of the engine
@ -48,6 +55,7 @@ app = FastAPI(
# --- Error handlers --- # --- Error handlers ---
@app.exception_handler(404) @app.exception_handler(404)
async def not_found_handler(request: Request, exc: Any) -> JSONResponse: async def not_found_handler(request: Request, exc: Any) -> JSONResponse:
"""Consistent 404 error envelope.""" """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. Timestamps are stored as ISO8601 TEXT columns.
""" """
from app.models.settings import Settings
from app.models.document import Document
from app.models.annotation_state import AnnotationState from app.models.annotation_state import AnnotationState
from app.models.document import Document
from app.models.settings import Settings
from app.models.version import Version from app.models.version import Version
__all__ = ["Settings", "Document", "AnnotationState", "Version"] __all__ = ["AnnotationState", "Document", "Settings", "Version"]

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,17 @@
from datetime import datetime from datetime import datetime
from typing import Literal, Union, List, Tuple, Optional from typing import Any, Literal
from pydantic import BaseModel, Field
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field
class Rect(BaseModel): class Rect(BaseModel):
x: float x: float
y: float y: float
width: float width: float
height: float height: float
class AnnotationBase(BaseModel): class AnnotationBase(BaseModel):
id: UUID id: UUID
page: int = Field(ge=0) page: int = Field(ge=0)
@ -19,6 +22,7 @@ class AnnotationBase(BaseModel):
createdAt: datetime createdAt: datetime
updatedAt: datetime updatedAt: datetime
class TextProps(BaseModel): class TextProps(BaseModel):
text: str text: str
fontFamily: str = "Liberation Sans" fontFamily: str = "Liberation Sans"
@ -28,82 +32,105 @@ class TextProps(BaseModel):
bold: bool = False bold: bool = False
italic: bool = False italic: bool = False
lineHeight: float = 1.2 lineHeight: float = 1.2
highlightColor: Optional[str] = None highlightColor: str | None = None
styles: Optional[dict] = None styles: dict[str, Any] | None = None
class TextAnnotation(AnnotationBase): class TextAnnotation(AnnotationBase):
type: Literal["text"] type: Literal["text"]
props: TextProps props: TextProps
class DrawProps(BaseModel): class DrawProps(BaseModel):
paths: List[Tuple[float, float]] = [] paths: list[tuple[float, float]] = Field(default_factory=list)
svgPath: Optional[str] = None svgPath: str | None = None
strokeColor: str = "#000000" strokeColor: str = "#000000"
strokeWidth: float = 2 strokeWidth: float = 2
opacity: float = 1.0 opacity: float = 1.0
class DrawAnnotation(AnnotationBase): class DrawAnnotation(AnnotationBase):
type: Literal["draw"] type: Literal["draw"]
props: DrawProps props: DrawProps
class SignatureDrawProps(BaseModel): class SignatureDrawProps(BaseModel):
mode: Literal["draw"] mode: Literal["draw"]
ref: str ref: str
strokeColor: str = "#000000" strokeColor: str = "#000000"
class SignatureTypeProps(BaseModel): class SignatureTypeProps(BaseModel):
mode: Literal["type"] mode: Literal["type"]
text: str text: str
fontFamily: str fontFamily: str
color: str = "#000000" color: str = "#000000"
class SignatureAnnotation(AnnotationBase): class SignatureAnnotation(AnnotationBase):
type: Literal["signature"] type: Literal["signature"]
props: Union[SignatureDrawProps, SignatureTypeProps] = Field(discriminator="mode") props: SignatureDrawProps | SignatureTypeProps = Field(discriminator="mode")
class ImageProps(BaseModel): class ImageProps(BaseModel):
ref: str ref: str
naturalWidth: float naturalWidth: float
naturalHeight: float naturalHeight: float
class ImageAnnotation(AnnotationBase): class ImageAnnotation(AnnotationBase):
type: Literal["image"] type: Literal["image"]
props: ImageProps props: ImageProps
class HighlightProps(BaseModel): class HighlightProps(BaseModel):
color: str = "#FFEB3B" color: str = "#FFEB3B"
opacity: float = 0.3 opacity: float = 0.3
class HighlightAnnotation(AnnotationBase): class HighlightAnnotation(AnnotationBase):
type: Literal["highlight"] type: Literal["highlight"]
props: HighlightProps props: HighlightProps
class ShapeProps(BaseModel): class ShapeProps(BaseModel):
kind: Literal["rect", "ellipse", "line", "arrow"] kind: Literal["rect", "ellipse", "line", "arrow"]
strokeColor: str = "#000000" strokeColor: str = "#000000"
fillColor: str = "transparent" fillColor: str = "transparent"
strokeWidth: float = 2 strokeWidth: float = 2
start: tuple[float, float] | None = None
end: tuple[float, float] | None = None
class ShapeAnnotation(AnnotationBase): class ShapeAnnotation(AnnotationBase):
type: Literal["shape"] type: Literal["shape"]
props: ShapeProps props: ShapeProps
Annotation = Union[
TextAnnotation, KnownAnnotation = (
DrawAnnotation, TextAnnotation
SignatureAnnotation, | DrawAnnotation
ImageAnnotation, | SignatureAnnotation
HighlightAnnotation, | ImageAnnotation
ShapeAnnotation | 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]
class AnnotationStateResponse(BaseModel): class AnnotationStateResponse(BaseModel):
data: List[Annotation] data: list[AnnotationPayload]
updatedAt: datetime updatedAt: datetime
class AnnotationStateUpdateRequest(BaseModel): class AnnotationStateUpdateRequest(BaseModel):
data: List[Annotation] data: list[AnnotationPayload]
baseUpdatedAt: datetime | None = None baseUpdatedAt: datetime | None = None
class AnnotationStateUpdateResponse(BaseModel): class AnnotationStateUpdateResponse(BaseModel):
updatedAt: datetime updatedAt: datetime

View file

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

View file

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

View file

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

View file

@ -0,0 +1,35 @@
"""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

@ -0,0 +1,12 @@
"""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

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,121 @@
"""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

@ -0,0 +1,121 @@
"""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,12 +30,18 @@ dev = [
requires = ["setuptools>=75.0"] requires = ["setuptools>=75.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["app*"]
[tool.ruff] [tool.ruff]
target-version = "py312" target-version = "py312"
line-length = 100 line-length = 100
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"] 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] [tool.mypy]
python_version = "3.12" python_version = "3.12"
@ -43,6 +49,21 @@ strict = true
warn_return_any = true warn_return_any = true
warn_unused_configs = 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] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["app/tests"] testpaths = ["app/tests"]

View file

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

36
docker-entrypoint.sh Normal file
View file

@ -0,0 +1,36 @@
#!/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"

View file

@ -1,24 +0,0 @@
# 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,73 +1,20 @@
# React + TypeScript + Vite # PaperJet frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. This directory contains the React/Vite client for PaperJet. Run the full stack
from the repository root; the root [README](../README.md) has Docker and local
development instructions.
Currently, two official plugins are available: ## Useful commands
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) ```sh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) npm ci
npm run dev # Vite with /api proxied to localhost:8000
## React Compiler npm test -- --run
npm run typecheck
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). npm run lint
npm run build
## 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...
},
},
])
``` ```
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: 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
```js not write PDF bytes directly.
// 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,10 +2,12 @@ server {
listen 80; listen 80;
server_name _; server_name _;
access_log /dev/stdout;
error_log /dev/stderr warn;
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
types { types {
application/javascript mjs; application/javascript mjs;
application/wasm wasm;
} }
# Match MAX_UPLOAD_MB raise in both this and the outer reverse proxy # Match MAX_UPLOAD_MB raise in both this and the outer reverse proxy
@ -25,9 +27,9 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Proxy /api/ to the backend container # Proxy /api/ to the loopback-only FastAPI process in this container
location /api/ { location /api/ {
proxy_pass http://backend:8000; proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import * as fabric from 'fabric'; import * as fabric from 'fabric';
import { useEditorStore } from '../store'; import { useEditorStore } from '../store';
import { getTool } from '../../../lib/annotations/registry'; import { getTool } from '../../../lib/annotations/registry';
import type { ViewportParams } from '../../../lib/coords'; import type { ViewportParams } from '../../../lib/coords';
import { screenToPdf } from '../../../lib/coords'; import { screenRectToPdf } from '../../../lib/coords';
import { TextFormatToolbar } from '../toolbar/TextFormatToolbar'; import { TextFormatToolbar } from '../toolbar/TextFormatToolbar';
interface AnnotationLayerProps { interface AnnotationLayerProps {
@ -13,208 +13,201 @@ interface AnnotationLayerProps {
viewportParams: ViewportParams; viewportParams: ViewportParams;
} }
type AnnotationObject = fabric.FabricObject & { id?: string };
export function AnnotationLayer({ pageNumber, width, height, viewportParams }: AnnotationLayerProps) { export function AnnotationLayer({ pageNumber, width, height, viewportParams }: AnnotationLayerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<fabric.Canvas | null>(null); const fabricRef = useRef<fabric.Canvas | null>(null);
const renderingIds = useRef(new Set<string>());
const activeToolName = useEditorStore(state => state.activeTool); const renderGeneration = useRef(0);
const annotations = useEditorStore(state => state.annotations);
const latestViewportParams = useRef(viewportParams); 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(() => { useEffect(() => {
latestViewportParams.current = viewportParams; latestViewportParams.current = viewportParams;
}, [viewportParams]); }, [viewportParams]);
// Initial setup and event binding
useEffect(() => { useEffect(() => {
if (!canvasRef.current) return; if (!canvasRef.current) return;
const pendingRenderIds = renderingIds.current;
const canvas = new fabric.Canvas(canvasRef.current, { const canvas = new fabric.Canvas(canvasRef.current, {
width, width,
height, height,
selection: activeToolName === 'select', 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; const updateSelection = () => {
const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
// Sync fabric modifications back to Zustand useEditorStore.getState().setSelection(activeObject?.id ?? null);
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 = (_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:created', updateSelection);
canvas.on('selection:updated', updateSelection); canvas.on('selection:updated', updateSelection);
canvas.on('selection:cleared', updateSelection); canvas.on('selection:cleared', updateSelection);
// Keyboard shortcut for delete const handleKeyDown = (event: KeyboardEvent) => {
const handleKeyDown = (e: KeyboardEvent) => { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
// Don't intercept if user is typing in an input field outside of canvas if (event.key !== 'Delete' && event.key !== 'Backspace') return;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
return; 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);
} }
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.discardActiveObject();
canvas.requestRenderAll(); canvas.requestRenderAll();
e.preventDefault(); event.preventDefault();
}
}
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
fabricRef.current = canvas;
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
pendingRenderIds.clear();
renderGeneration.current += 1;
canvas.dispose(); canvas.dispose();
fabricRef.current = null; fabricRef.current = null;
setCanvasInstance(null);
}; };
}, []); // Run once on mount // The canvas is created once for this page. Dimension and tool changes are
// handled by the effects below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const prevScaleRef = useRef(viewportParams.scale);
// Handle dimensions and re-rendering annotations
useEffect(() => {
if (!fabricRef.current) return;
const canvas = fabricRef.current;
canvas.setDimensions({ width, height });
const activeObj = canvas.getActiveObject() as any;
const activeId = activeObj?.id;
const pageAnns = annotations.filter(a => a.page === pageNumber);
const annIds = new Set(pageAnns.map(a => a.id));
const scaleChanged = prevScaleRef.current !== viewportParams.scale;
prevScaleRef.current = viewportParams.scale;
// Remove objects that are no longer in annotations, OR if the zoom scale changed
canvas.getObjects().forEach((obj: any) => {
// Don't remove the active object unless it was deleted from the store,
// UNLESS the scale changed (we must recreate everything on zoom)
if (!scaleChanged && obj.id === activeId && annIds.has(activeId)) return;
if (scaleChanged || !annIds.has(obj.id)) {
canvas.remove(obj);
}
});
// Add missing annotations
const canvasObjIds = new Set(canvas.getObjects().map((o: any) => o.id));
pageAnns.forEach(ann => {
if (!scaleChanged && ann.id === activeId) return; // Skip rendering the active object
if (!canvasObjIds.has(ann.id)) {
const tool = getTool(ann.type);
if (tool && tool.renderToFabric) {
tool.renderToFabric(ann, canvas, viewportParams);
}
}
});
if (scaleChanged && activeId && annIds.has(activeId)) {
const newlyRenderedActiveObj = canvas.getObjects().find((o: any) => o.id === activeId);
if (newlyRenderedActiveObj) {
canvas.setActiveObject(newlyRenderedActiveObj);
}
}
}, [width, height, annotations, pageNumber, viewportParams.scale]);
// Handle tool activation/deactivation and event binding
useEffect(() => { useEffect(() => {
const canvas = fabricRef.current; const canvas = fabricRef.current;
if (!canvas) return; if (!canvas) return;
const generation = ++renderGeneration.current;
renderingIds.current.clear();
canvas.setDimensions({ width, height });
// First deactivate any previous tool logic const pageAnnotations = annotations.filter((annotation) => annotation.page === pageNumber);
// This is a bit tricky if we don't remember the previous tool, const annotationIds = new Set(pageAnnotations.map((annotation) => annotation.id));
// but we can just clear event listeners. 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 };
for (const object of canvas.getObjects() as AnnotationObject[]) {
if (!object.id || !annotationIds.has(object.id) || scaleChanged) {
canvas.remove(object);
}
}
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 (activeId) {
const renderedActive = canvas.getObjects().find(
(object) => (object as AnnotationObject).id === activeId,
);
if (renderedActive) canvas.setActiveObject(renderedActive);
}
canvas.requestRenderAll();
}, [annotations, height, pageNumber, viewportParams, width]);
useEffect(() => {
const canvas = fabricRef.current;
if (!canvas) return;
canvas.off('mouse:down'); canvas.off('mouse:down');
canvas.off('mouse:move'); canvas.off('mouse:move');
canvas.off('mouse:up'); canvas.off('mouse:up');
canvas.off('path:created');
canvas.selection = activeToolName === 'select'; canvas.selection = activeToolName === 'select';
const tool = getTool(activeToolName); const tool = getTool(activeToolName);
if (tool) { if (!tool) return;
if (tool.onActivate) tool.onActivate(canvas); tool.onActivate?.(canvas);
if (tool.onPointerDown) { if (tool.onPointerDown) {
canvas.on('mouse:down', (e) => tool.onPointerDown!(e, canvas, latestViewportParams.current, pageNumber)); canvas.on('mouse:down', (event) =>
tool.onPointerDown?.(event, canvas, latestViewportParams.current, pageNumber),
);
} }
if (tool.onPointerMove) { if (tool.onPointerMove) {
canvas.on('mouse:move', (e) => tool.onPointerMove!(e, canvas, latestViewportParams.current, pageNumber)); canvas.on('mouse:move', (event) =>
tool.onPointerMove?.(event, canvas, latestViewportParams.current, pageNumber),
);
} }
if (tool.onPointerUp) { if (tool.onPointerUp) {
canvas.on('mouse:up', (e) => tool.onPointerUp!(e, canvas, latestViewportParams.current, pageNumber)); canvas.on('mouse:up', (event) =>
tool.onPointerUp?.(event, canvas, latestViewportParams.current, pageNumber),
);
} }
if (tool.onPathCreated) { if (tool.onPathCreated) {
canvas.on('path:created', (e) => tool.onPathCreated!(e, canvas, latestViewportParams.current, pageNumber)); canvas.on('path:created', (event) =>
} tool.onPathCreated?.(event, canvas, latestViewportParams.current, pageNumber),
);
} }
return () => tool.onDeactivate?.(canvas);
}, [activeToolName, pageNumber]);
return () => { const activeAnnotation =
if (tool && tool.onDeactivate) { draftAnnotation?.page === pageNumber
tool.onDeactivate(canvas); ? draftAnnotation
} : annotations.find((annotation) => annotation.id === selection && annotation.page === pageNumber);
};
}, [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 ( return (
<div className="absolute top-0 left-0" style={{ width, height }}> <div className="absolute left-0 top-0" style={{ width, height }}>
<canvas ref={canvasRef} /> <canvas ref={canvasRef} />
{toolbarAnnotationId && activeAnn && ( {activeAnnotation?.type === 'text' && canvasInstance && (
<> <TextFormatToolbar
{activeAnn.type === 'text' && <TextFormatToolbar annotationId={toolbarAnnotationId} viewportParams={viewportParams} />} annotationId={activeAnnotation.id}
{/* We will add ShapeControls and DrawControls here once they are implemented */} viewportParams={viewportParams}
</> canvas={canvasInstance}
/>
)} )}
</div> </div>
); );

View file

@ -1,87 +1,127 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'; import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
import { AnnotationLayer } from '../canvas/AnnotationLayer'; import { AnnotationLayer } from '../canvas/AnnotationLayer';
interface PageRendererProps { interface PageRendererProps {
pdfDoc: PDFDocumentProxy; pdfDoc: PDFDocumentProxy;
pageNumber: number; pageIndex: number;
scale: number; scale: number;
dpr: number; dpr: number;
} }
export function PageRenderer({ pdfDoc, pageNumber, scale, dpr }: PageRendererProps) { export function PageRenderer({ pdfDoc, pageIndex, scale, dpr }: PageRendererProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [page, setPage] = useState<PDFPageProxy | null>(null); const [page, setPage] = useState<PDFPageProxy | null>(null);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
pdfDoc.getPage(pageNumber).then(p => { const pageNumber = pageIndex + 1;
if (active) setPage(p); pdfDoc.getPage(pageNumber).then((nextPage) => {
if (active) setPage(nextPage);
}); });
return () => { active = false; };
}, [pdfDoc, pageNumber]);
useEffect(() => {
if (!page || !canvasRef.current) return;
const viewport = page.getViewport({ scale: scale * dpr });
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width / dpr}px`;
canvas.style.height = `${viewport.height / dpr}px`;
const renderContext = {
canvas: canvas,
viewport: viewport,
};
let renderTask = page.render(renderContext);
return () => { return () => {
renderTask.cancel(); active = false;
}; };
}, [page, scale, dpr]); }, [pdfDoc, pageIndex]);
if (!page) { if (!page) {
return ( return (
<div <div
className="bg-white shadow-sm flex items-center justify-center text-neutral-400 border border-neutral-200" className="flex items-center justify-center border border-neutral-200 bg-white text-neutral-400 shadow-sm"
style={{ width: 612 * scale, height: 792 * scale }} style={{ width: 612 * scale, height: 792 * scale }}
> >
Loading page {pageNumber}... Loading page {pageIndex + 1}...
</div> </div>
); );
} }
const baseViewport = page ? page.getViewport({ scale: 1, rotation: 0 }) : null; const canonicalViewport = page.getViewport({ scale: 1, rotation: 0 });
const renderedViewport = page.getViewport({
scale: scale * dpr,
rotation: page.rotate,
});
const cssWidth = renderedViewport.width / dpr;
const cssHeight = renderedViewport.height / dpr;
return ( return (
<div className="relative shadow-xl bg-white border border-neutral-200 group"> <RenderedPage
<canvas ref={canvasRef} className="block" /> page={page}
pageIndex={pageIndex}
scale={scale}
dpr={dpr}
canonicalWidth={canonicalViewport.width}
canonicalHeight={canonicalViewport.height}
renderedWidth={renderedViewport.width}
renderedHeight={renderedViewport.height}
cssWidth={cssWidth}
cssHeight={cssHeight}
/>
);
}
{baseViewport && ( interface RenderedPageProps {
<AnnotationLayer page: PDFPageProxy;
pageNumber={pageNumber} pageIndex: number;
width={baseViewport.width * scale} scale: number;
height={baseViewport.height * scale} dpr: number;
viewportParams={{ 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, scale,
rotation: page.rotate, rotation: page.rotate,
canonicalWidth: baseViewport.width, canonicalWidth,
canonicalHeight: baseViewport.height, canonicalHeight,
dpr dpr,
}} }), [canonicalHeight, canonicalWidth, dpr, page.rotate, scale]);
/>
)}
<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"> useEffect(() => {
{pageNumber} const canvas = document.querySelector<HTMLCanvasElement>(
`[data-paperjet-page="${pageIndex}"]`,
);
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
canvas.width = renderedWidth;
canvas.height = renderedHeight;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
const renderTask = page.render({ canvas, viewport: page.getViewport({ scale: scale * dpr, rotation: page.rotate }) });
renderTask.promise.catch(() => undefined);
return () => {
renderTask.cancel();
};
}, [page, pageIndex, scale, dpr, renderedWidth, renderedHeight, cssWidth, cssHeight]);
return (
<div className="group relative border border-neutral-200 bg-white shadow-xl" style={{ width: cssWidth, height: cssHeight }}>
<canvas data-paperjet-page={pageIndex} className="block" />
<AnnotationLayer
pageNumber={pageIndex}
width={cssWidth}
height={cssHeight}
viewportParams={viewportParams}
/>
<div className="absolute -left-10 top-2 rounded-md border border-neutral-200 bg-white/80 px-1.5 py-0.5 text-xs font-bold text-neutral-400 shadow-sm">
{pageIndex + 1}
</div> </div>
</div> </div>
); );

View file

@ -8,12 +8,13 @@ interface PageStackProps {
export function PageStack({ pdfDoc }: PageStackProps) { export function PageStack({ pdfDoc }: PageStackProps) {
const numPages = pdfDoc.numPages; const numPages = pdfDoc.numPages;
const pages = Array.from({ length: numPages }, (_, i) => i + 1); const pages = Array.from({ length: numPages }, (_, i) => i);
const zoom = useEditorStore(state => state.zoom); const zoom = useEditorStore(state => state.zoom);
// For now, render all pages vertically. Virtualization comes in Phase 7. // PDF points map directly to CSS pixels at 100%. Device pixel ratio is
const scale = zoom * 2.0; // applied only to the PDF.js backing canvas, not the annotation overlay.
const scale = zoom;
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
return ( return (
@ -22,7 +23,7 @@ export function PageStack({ pdfDoc }: PageStackProps) {
<PageRenderer <PageRenderer
key={pageNum} key={pageNum}
pdfDoc={pdfDoc} pdfDoc={pdfDoc}
pageNumber={pageNum} pageIndex={pageNum}
scale={scale} scale={scale}
dpr={dpr} dpr={dpr}
/> />

View file

@ -4,50 +4,61 @@ import { PageStack } from './PageStack';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs', 'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url import.meta.url,
).toString(); ).toString();
interface LoadedDocument {
id: string;
document: pdfjsLib.PDFDocumentProxy;
}
interface DocumentError {
id: string;
message: string;
}
export function PdfDocument({ documentId }: { documentId: string }) { export function PdfDocument({ documentId }: { documentId: string }) {
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null); const [loaded, setLoaded] = useState<LoadedDocument | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<DocumentError | null>(null);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
const url = `/api/v1/documents/${documentId}/file`; const loadingTask = pdfjsLib.getDocument({ url: `/api/v1/documents/${documentId}/file` });
const loadingTask = pdfjsLib.getDocument({ url }); loadingTask.promise.then((document) => {
if (active) setLoaded({ id: documentId, document });
loadingTask.promise.then((doc) => { }).catch((reason: unknown) => {
if (active) setPdfDoc(doc); if (active) {
}).catch(err => { const message = reason instanceof Error ? reason.message : 'Unable to load this PDF.';
console.error('Failed to load PDF', err); setError({ id: documentId, message });
if (active) setError(err.message); }
}); });
return () => { return () => {
active = false; active = false;
loadingTask.destroy(); void loadingTask.destroy();
}; };
}, [documentId]); }, [documentId]);
if (error) { const currentError = error?.id === documentId ? error.message : null;
const currentDocument = loaded?.id === documentId ? loaded.document : null;
if (currentError) {
return ( return (
<div className="flex items-center justify-center h-full w-full"> <div className="flex h-full w-full items-center justify-center">
<div className="text-red-500 bg-red-50 px-4 py-3 rounded-lg border border-red-200"> <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-600">Error loading PDF: {currentError}</div>
Error loading PDF: {error}
</div>
</div> </div>
); );
} }
if (!pdfDoc) { if (!currentDocument) {
return ( return (
<div className="flex flex-col items-center justify-center h-full w-full gap-4"> <div className="flex h-full w-full flex-col items-center justify-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500"></div> <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-accent-500" />
<div className="text-neutral-500 font-medium animate-pulse">Loading document...</div> <div className="font-medium animate-pulse text-neutral-500">Loading document</div>
</div> </div>
); );
} }
return <PageStack pdfDoc={pdfDoc} />; return <PageStack pdfDoc={currentDocument} />;
} }

View file

@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useEditorStore } from './store';
import type { TextAnnotation } from '../../lib/annotations/types';
const annotation: TextAnnotation = {
id: 'note-1',
page: 0,
type: 'text',
rect: { x: 10, y: 20, width: 100, height: 30 },
rotation: 0,
z: 0,
props: {
text: 'Original',
fontFamily: 'Liberation Sans',
fontSize: 14,
color: '#000000',
align: 'left',
bold: false,
italic: false,
lineHeight: 1.2,
},
createdAt: '2026-08-14T00:00:00Z',
updatedAt: '2026-08-14T00:00:00Z',
};
beforeEach(() => {
useEditorStore.getState().setDocumentId(null);
});
describe('editor history', () => {
it('undoes and redoes annotation mutations without sharing mutable state', () => {
const store = useEditorStore.getState();
store.setDocumentId('document-1');
store.addAnnotation(annotation);
store.updateAnnotation('note-1', { props: { ...annotation.props, text: 'Updated' } });
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Updated' });
useEditorStore.getState().undo();
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Original' });
useEditorStore.getState().redo();
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Updated' });
});
it('clears working state and history when switching documents', () => {
const store = useEditorStore.getState();
store.setDocumentId('document-1');
store.addAnnotation(annotation);
store.setDocumentId('document-2');
const next = useEditorStore.getState();
expect(next.documentId).toBe('document-2');
expect(next.annotations).toEqual([]);
expect(next.past).toEqual([]);
expect(next.future).toEqual([]);
});
});

View file

@ -1,74 +1,171 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { Annotation, TextProps } from '../../lib/annotations/types'; import type {
Annotation,
SignatureDrawProps,
SignatureTypeProps,
TextProps,
} from '../../lib/annotations/types';
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; export type SaveStatus = 'idle' | 'loading' | 'saving' | 'saved' | 'error';
type SignatureProps = SignatureDrawProps | SignatureTypeProps;
type PendingImage = { ref: string; width: number; height: number };
interface EditorState { interface EditorState {
documentId: string | null; documentId: string | null;
annotations: Annotation[]; annotations: Annotation[];
activeTool: string; past: Annotation[][];
future: Annotation[][];
selection: string | null; selection: string | null;
activeTool: string;
activeShapeKind: 'rect' | 'ellipse' | 'line' | 'arrow';
zoom: number; zoom: number;
saveStatus: SaveStatus; saveStatus: SaveStatus;
annotationUpdatedAt: string | null;
defaultTextProps: Partial<TextProps>; defaultTextProps: Partial<TextProps>;
draftAnnotation: Annotation | null; draftAnnotation: Annotation | null;
isSignatureModalOpen: boolean; isSignatureModalOpen: boolean;
pendingSignatureProps: any | null; pendingSignatureProps: SignatureProps | null;
pendingImageRef: { ref: string, width: number, height: number } | null; pendingImageRef: PendingImage | null;
// Actions
setDocumentId: (id: string | null) => void; setDocumentId: (id: string | null) => void;
setAnnotations: (annotations: Annotation[]) => void; setAnnotations: (annotations: Annotation[]) => void;
setAnnotationUpdatedAt: (updatedAt: string | null) => void;
addAnnotation: (annotation: Annotation) => void; addAnnotation: (annotation: Annotation) => void;
updateAnnotation: (id: string, updates: Partial<Annotation>) => void; updateAnnotation: (id: string, updates: Partial<Annotation>) => void;
deleteAnnotation: (id: string) => void; deleteAnnotation: (id: string) => void;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
setActiveTool: (tool: string) => void; setActiveTool: (tool: string) => void;
setSelection: (id: string | null) => void; setSelection: (id: string | null) => void;
setZoom: (zoom: number) => void; setZoom: (zoom: number) => void;
setSaveStatus: (status: SaveStatus) => void; setSaveStatus: (status: SaveStatus) => void;
setDefaultTextProps: (props: Partial<TextProps>) => void; setDefaultTextProps: (props: Partial<TextProps>) => void;
setDraftAnnotation: (ann: Annotation | null) => void; setDraftAnnotation: (annotation: Annotation | null) => void;
setIsSignatureModalOpen: (isOpen: boolean) => void; setIsSignatureModalOpen: (isOpen: boolean) => void;
setPendingSignatureProps: (props: any | null) => void; setPendingSignatureProps: (props: SignatureProps | null) => void;
setPendingImageRef: (imgRef: { ref: string, width: number, height: number } | null) => void; setPendingImageRef: (image: PendingImage | null) => void;
activeShapeKind: 'rect' | 'ellipse' | 'line'; setActiveShapeKind: (kind: EditorState['activeShapeKind']) => void;
setActiveShapeKind: (kind: 'rect' | 'ellipse' | 'line') => void;
} }
export const useEditorStore = create<EditorState>((set) => ({ const MAX_HISTORY = 100;
function cloneAnnotations(annotations: Annotation[]): Annotation[] {
return annotations.map((annotation) => ({
...annotation,
rect: { ...annotation.rect },
props: structuredClone(annotation.props),
})) as Annotation[];
}
function withHistory(state: EditorState, annotations: Annotation[]): Partial<EditorState> {
return {
annotations,
past: [...state.past, cloneAnnotations(state.annotations)].slice(-MAX_HISTORY),
future: [],
};
}
export const useEditorStore = create<EditorState>((set, get) => ({
documentId: null, documentId: null,
annotations: [], annotations: [],
activeTool: 'select', past: [],
future: [],
selection: null, selection: null,
activeTool: 'select',
activeShapeKind: 'rect',
zoom: 1, zoom: 1,
saveStatus: 'idle', saveStatus: 'idle',
annotationUpdatedAt: null,
defaultTextProps: {}, defaultTextProps: {},
draftAnnotation: null, draftAnnotation: null,
isSignatureModalOpen: false, isSignatureModalOpen: false,
pendingSignatureProps: null, pendingSignatureProps: null,
pendingImageRef: null, pendingImageRef: null,
activeShapeKind: 'rect',
setDocumentId: (id) => set({ documentId: id, defaultTextProps: {}, draftAnnotation: null }), setDocumentId: (id) =>
setAnnotations: (annotations) => set({ annotations }), set({
addAnnotation: (annotation) => set((state) => ({ documentId: id,
annotations: [...state.annotations, annotation] annotations: [],
})), past: [],
updateAnnotation: (id, updates) => set((state) => ({ future: [],
annotations: state.annotations.map(a => a.id === id ? { ...a, ...updates } as Annotation : a) selection: null,
})), saveStatus: id ? 'loading' : 'idle',
deleteAnnotation: (id) => set((state) => ({ annotationUpdatedAt: null,
annotations: state.annotations.filter(a => a.id !== id), defaultTextProps: {},
selection: state.selection === id ? null : state.selection draftAnnotation: null,
})), pendingSignatureProps: null,
pendingImageRef: null,
}),
setAnnotations: (annotations) =>
set({
annotations: cloneAnnotations(annotations),
past: [],
future: [],
selection: null,
draftAnnotation: null,
}),
setAnnotationUpdatedAt: (updatedAt) => set({ annotationUpdatedAt: updatedAt }),
addAnnotation: (annotation) =>
set((state) => withHistory(state, cloneAnnotations([...state.annotations, annotation]))),
updateAnnotation: (id, updates) =>
set((state) => {
const current = state.annotations.find((annotation) => annotation.id === id);
if (!current) return state;
const next = state.annotations.map((annotation) =>
annotation.id === id
? ({
...annotation,
...updates,
props: updates.props ? { ...annotation.props, ...updates.props } : annotation.props,
updatedAt: new Date().toISOString(),
} as Annotation)
: annotation,
);
return withHistory(state, next);
}),
deleteAnnotation: (id) =>
set((state) => {
if (!state.annotations.some((annotation) => annotation.id === id)) return state;
return {
...withHistory(state, state.annotations.filter((annotation) => annotation.id !== id)),
selection: state.selection === id ? null : state.selection,
};
}),
undo: () =>
set((state) => {
const previous = state.past.at(-1);
if (!previous) return state;
return {
annotations: cloneAnnotations(previous),
past: state.past.slice(0, -1),
future: [cloneAnnotations(state.annotations), ...state.future].slice(0, MAX_HISTORY),
selection: null,
};
}),
redo: () =>
set((state) => {
const next = state.future[0];
if (!next) return state;
return {
annotations: cloneAnnotations(next),
past: [...state.past, cloneAnnotations(state.annotations)].slice(-MAX_HISTORY),
future: state.future.slice(1),
selection: null,
};
}),
canUndo: () => get().past.length > 0,
canRedo: () => get().future.length > 0,
setActiveTool: (tool) => set({ activeTool: tool }), setActiveTool: (tool) => set({ activeTool: tool }),
setSelection: (id) => set({ selection: id }), setSelection: (id) => set({ selection: id }),
setZoom: (zoom) => set({ zoom }), setZoom: (zoom) => set({ zoom: Math.max(0.5, Math.min(3, zoom)) }),
setSaveStatus: (status) => set({ saveStatus: status }), setSaveStatus: (status) => set({ saveStatus: status }),
setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })), setDefaultTextProps: (props) =>
setDraftAnnotation: (ann) => set({ draftAnnotation: ann }), set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
setDraftAnnotation: (annotation) => set({ draftAnnotation: annotation }),
setIsSignatureModalOpen: (isOpen) => set({ isSignatureModalOpen: isOpen }), setIsSignatureModalOpen: (isOpen) => set({ isSignatureModalOpen: isOpen }),
setPendingSignatureProps: (props) => set({ pendingSignatureProps: props }), setPendingSignatureProps: (props) => set({ pendingSignatureProps: props }),
setPendingImageRef: (imgRef) => set({ pendingImageRef: imgRef }), setPendingImageRef: (image) => set({ pendingImageRef: image }),
setActiveShapeKind: (kind) => set({ activeShapeKind: kind }), setActiveShapeKind: (kind) => set({ activeShapeKind: kind }),
})); }));

View file

@ -1,12 +1,44 @@
import { useRef, useState } from 'react'; import { useRef, useState, type ChangeEvent } from 'react';
import { useEditorStore } from '../store'; import {
Download,
History,
Highlighter,
ImagePlus,
Minus,
MousePointer2,
PenLine,
Redo2,
Signature,
Square,
Type,
Undo2,
ZoomIn,
} from 'lucide-react';
import { SignatureModal } from '../tools/SignatureModal'; import { SignatureModal } from '../tools/SignatureModal';
import { api } from '../../../lib/api/client'; import { api } from '../../../lib/api/client';
import { useEditorStore } from '../store';
import type { SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types';
export function EditorToolbar() { 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 { const {
activeTool, activeTool,
setActiveTool, setActiveTool,
activeShapeKind,
setActiveShapeKind,
saveStatus, saveStatus,
zoom, zoom,
setZoom, setZoom,
@ -14,144 +46,113 @@ export function EditorToolbar() {
setIsSignatureModalOpen, setIsSignatureModalOpen,
setPendingSignatureProps, setPendingSignatureProps,
setPendingImageRef, setPendingImageRef,
documentId documentId,
past,
future,
undo,
redo,
} = useEditorStore(); } = useEditorStore();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploadingImage, setIsUploadingImage] = useState(false); const [isUploadingImage, setIsUploadingImage] = useState(false);
const handleZoomIn = () => setZoom(Math.min(zoom + 0.25, 3)); const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const handleZoomOut = () => setZoom(Math.max(zoom - 0.25, 0.5)); const file = event.target.files?.[0];
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !documentId) return; if (!file || !documentId) return;
setIsUploadingImage(true); setIsUploadingImage(true);
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); 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 }); setPendingImageRef({ ref: data.ref, width: 0, height: 0 });
setActiveTool('image'); setActiveTool('image');
} catch (err) { } catch (reason) {
console.error(err); console.error('Failed to upload image', reason);
alert('Failed to upload image'); window.alert(reason instanceof Error ? reason.message : 'Failed to upload image.');
} finally { } finally {
setIsUploadingImage(false); setIsUploadingImage(false);
// Reset input
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
} }
}; };
const handleSignatureConfirm = (props: any) => { const handleSignatureConfirm = (props: SignatureDrawProps | SignatureTypeProps) => {
setPendingSignatureProps(props); setPendingSignatureProps(props);
setIsSignatureModalOpen(false); setIsSignatureModalOpen(false);
setActiveTool('signature'); setActiveTool('signature');
}; };
const isActive = (name: string) => activeTool === name ? active : inactive;
return ( return (
<> <>
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-neutral-200 px-2 py-2 flex items-center gap-2 z-50"> <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 <button type="button" className={`${toolButton} ${isActive('select')}`} onClick={() => setActiveTool('select')} title="Select and move annotations">
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${ <MousePointer2 className="h-4 w-4" /> Select
activeTool === 'select' </button>
? 'bg-blue-100 text-blue-700' <button type="button" className={`${toolButton} ${isActive('text')}`} onClick={() => setActiveTool('text')} title="Add text">
: 'text-neutral-600 hover:bg-neutral-100' <Type className="h-4 w-4" /> Text
}`} </button>
onClick={() => setActiveTool('select')} <button type="button" className={`${toolButton} ${isActive('draw')}`} onClick={() => setActiveTool('draw')} title="Draw freehand">
<PenLine className="h-4 w-4" /> Draw
</button>
<button type="button" className={`${toolButton} ${isActive('highlight')}`} onClick={() => setActiveTool('highlight')} title="Highlight an area">
<Highlighter className="h-4 w-4" /> Highlight
</button>
<button type="button" className={`${toolButton} ${isActive('shape')}`} onClick={() => setActiveTool('shape')} title="Draw a shape">
<Square className="h-4 w-4" /> Shape
</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"
> >
Select <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)} />
<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> </button>
<button <span className="mx-1 h-6 w-px bg-neutral-200" />
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${ <button type="button" className={`${toolButton} ${inactive}`} onClick={() => setZoom(zoom - 0.25)} disabled={zoom <= 0.5} title="Zoom out">
activeTool === 'text' <Minus className="h-4 w-4" />
? 'bg-blue-100 text-blue-700' </button>
: 'text-neutral-600 hover:bg-neutral-100' <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">
onClick={() => setActiveTool('text')} <ZoomIn className="h-4 w-4" />
>
Add Text
</button> </button>
<button <span className="mx-1 h-6 w-px bg-neutral-200" />
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${ <button type="button" className={`${toolButton} ${inactive}`} onClick={onOpenVersions} title="Open checkpoints">
activeTool === 'draw' <History className="h-4 w-4" /> History
? 'bg-blue-100 text-blue-700' </button>
: 'text-neutral-600 hover:bg-neutral-100' <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'}
onClick={() => setActiveTool('draw')}
>
Draw
</button> </button>
<span className="ml-1 min-w-16 text-center text-[11px] font-semibold text-neutral-500">
{saveStatus === 'loading' && 'Loading…'}
<button {saveStatus === 'saving' && 'Saving…'}
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
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>
<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"
>
-
</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>
<div className="w-px h-6 bg-neutral-200 mx-2" />
<div className="text-xs font-medium text-neutral-400 min-w-16 text-center">
{saveStatus === 'saving' && 'Saving...'}
{saveStatus === 'saved' && 'Saved'} {saveStatus === 'saved' && 'Saved'}
{saveStatus === 'error' && <span className="text-red-500">Error saving</span>} {saveStatus === 'error' && <span className="text-red-600">Save failed</span>}
{saveStatus === 'idle' && ''} </span>
</div>
</div> </div>
{isSignatureModalOpen && ( {isSignatureModalOpen && (

View file

@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import * as fabric from 'fabric';
import type { Canvas } from 'fabric';
import { useEditorStore } from '../store'; import { useEditorStore } from '../store';
import type { TextAnnotation, TextProps } from '../../../lib/annotations/types'; import type { TextAnnotation, TextProps } from '../../../lib/annotations/types';
import type { ViewportParams } from '../../../lib/coords'; import type { ViewportParams } from '../../../lib/coords';
@ -17,6 +19,7 @@ import {
interface TextFormatToolbarProps { interface TextFormatToolbarProps {
annotationId: string; annotationId: string;
viewportParams: ViewportParams; viewportParams: ViewportParams;
canvas: Canvas;
} }
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New']; const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New'];
@ -24,7 +27,7 @@ const SIZES = [6, 7, 8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff']; const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000']; const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000'];
export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatToolbarProps) { export function TextFormatToolbar({ annotationId, viewportParams, canvas }: TextFormatToolbarProps) {
const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore(); const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore();
const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null); const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null);
@ -51,11 +54,9 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
const top = pt.y - 48; // 48px above const top = pt.y - 48; // 48px above
const left = pt.x; const left = pt.x;
const applyStyle = (styleName: string, value: any, globalPropName: keyof TextProps, globalValue?: any) => { const applyStyle = (styleName: string, value: unknown, globalPropName: keyof TextProps, globalValue?: unknown) => {
const canvas = (window as any).__fabricCanvas as any; const activeObj = canvas.getActiveObject() as (fabric.Textbox & { id?: string; customHeight?: number }) | undefined;
if (activeObj) {
if (canvas) {
const activeObj = canvas.getActiveObject();
if (activeObj && activeObj.id === annotationId) { if (activeObj && activeObj.id === annotationId) {
const isStructural = styleName === 'fontSize' || styleName === 'fontFamily'; const isStructural = styleName === 'fontSize' || styleName === 'fontFamily';
@ -81,7 +82,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
activeObj.styles = {}; activeObj.styles = {};
if (activeObj.hiddenTextarea) { if (activeObj.hiddenTextarea) {
if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`; if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`;
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = value; if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value);
} }
} }
} else { } else {
@ -100,7 +101,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
for (const line in activeObj.styles) { for (const line in activeObj.styles) {
for (const char in activeObj.styles[line]) { for (const char in activeObj.styles[line]) {
if (activeObj.styles[line][char]) { if (activeObj.styles[line][char]) {
delete activeObj.styles[line][char][styleName]; delete (activeObj.styles[line][char] as Record<string, unknown>)[styleName];
} }
} }
} }
@ -110,8 +111,8 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
// Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw! // Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw!
activeObj.dirty = true; activeObj.dirty = true;
if ((activeObj as any)._forceClearCache !== undefined) { if ('_forceClearCache' in activeObj) {
(activeObj as any)._forceClearCache = true; (activeObj as typeof activeObj & { _forceClearCache?: boolean })._forceClearCache = true;
} }
// Remove manual height constraint so the box can grow with the new font size // Remove manual height constraint so the box can grow with the new font size
@ -123,7 +124,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
} }
const finalGlobalValue = globalValue !== undefined ? globalValue : value; const finalGlobalValue = globalValue !== undefined ? globalValue : value;
const newProps = { [globalPropName]: finalGlobalValue } as any; const newProps = { [globalPropName]: finalGlobalValue } as Partial<TextProps>;
setDefaultTextProps(newProps); setDefaultTextProps(newProps);
// Always update store so the toolbar displays the new value // Always update store so the toolbar displays the new value
@ -154,13 +155,10 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
const handleDelete = () => { const handleDelete = () => {
if (isDraft) { if (isDraft) {
useEditorStore.getState().setDraftAnnotation(null); useEditorStore.getState().setDraftAnnotation(null);
const canvas = (window as any).__fabricCanvas as any;
if (canvas) {
const activeObj = canvas.getActiveObject(); const activeObj = canvas.getActiveObject();
if (activeObj && activeObj.id === annotationId) { if (activeObj && (activeObj as fabric.FabricObject & { id?: string }).id === annotationId) {
canvas.remove(activeObj); canvas.remove(activeObj);
} }
}
} else { } else {
deleteAnnotation(annotationId); deleteAnnotation(annotationId);
} }
@ -220,7 +218,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
<button <button
key={s} 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' : ''}`} className={`w-full text-center px-3 py-1 text-xs hover:bg-blue-50 ${props.fontSize === s ? 'bg-blue-50 text-blue-600 font-medium' : ''}`}
onClick={() => { applyStyle('fontSize', s * viewportParams.scale * (viewportParams.dpr || 1), 'fontSize', s); setActiveDropdown(null); }} onClick={() => { applyStyle('fontSize', s * viewportParams.scale, 'fontSize', s); setActiveDropdown(null); }}
> >
{s} {s}
</button> </button>

View file

@ -6,6 +6,12 @@ import type { Annotation, DrawAnnotation, DrawProps } from '../../../lib/annotat
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords'; import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords'; import type { ViewportParams } from '../../../lib/coords';
type FabricAnnotationObject = fabric.FabricObject & {
id?: string;
annotationType?: string;
annotationProps?: DrawProps;
};
export const DrawTool: ToolHandler = { export const DrawTool: ToolHandler = {
name: 'draw', name: 'draw',
@ -21,7 +27,7 @@ export const DrawTool: ToolHandler = {
canvas.isDrawingMode = false; canvas.isDrawingMode = false;
}, },
onPathCreated: (e: any, _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { onPathCreated: (e: fabric.CanvasEvents['path:created'], _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
const pathObj = e.path as fabric.Path; const pathObj = e.path as fabric.Path;
pathObj.set({ pathObj.set({
@ -33,27 +39,16 @@ export const DrawTool: ToolHandler = {
padding: 5, padding: 5,
}); });
(pathObj as any).id = uuidv4(); const annotatedPath = pathObj as FabricAnnotationObject;
(pathObj as any).annotationType = 'draw'; annotatedPath.id = uuidv4();
annotatedPath.annotationType = 'draw';
// The path data is stored in `pathObj.path` array of commands, e.g. [['M', x, y], ['Q', cx, cy, x, y]] // Keep the SVG path for faithful browser re-rendering and also store a
// We can serialize it via pathObj.toObject().path or complexPathToString(pathObj.path) // canonical point list for the server export renderer.
// 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) const pathStr = Array.isArray(pathObj.path)
? pathObj.path.map(cmd => cmd.join(' ')).join(' ') ? pathObj.path.map(cmd => cmd.join(' ')).join(' ')
: pathObj.path; : 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 bounds = pathObj.getBoundingRect();
const pdfRect = screenRectToPdf({ const pdfRect = screenRectToPdf({
x: bounds.left, x: bounds.left,
@ -61,9 +56,36 @@ export const DrawTool: ToolHandler = {
width: bounds.width, width: bounds.width,
height: bounds.height height: bounds.height
}, vp); }, 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 = { const annotation: DrawAnnotation = {
id: (pathObj as any).id, id: annotatedPath.id as string,
page: pageNumber, page: pageNumber,
type: 'draw', type: 'draw',
rect: pdfRect, rect: pdfRect,
@ -81,7 +103,7 @@ export const DrawTool: ToolHandler = {
}, },
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => { renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'draw') return null; if (annotation.type !== 'draw') return;
const drawAnn = annotation as DrawAnnotation; const drawAnn = annotation as DrawAnnotation;
const props = drawAnn.props as DrawProps; const props = drawAnn.props as DrawProps;
const screenRect = pdfRectToScreen(drawAnn.rect, vp); const screenRect = pdfRectToScreen(drawAnn.rect, vp);
@ -103,6 +125,8 @@ export const DrawTool: ToolHandler = {
pathObj.set({ pathObj.set({
left: screenRect.x, left: screenRect.x,
top: screenRect.y, top: screenRect.y,
originX: 'left',
originY: 'top',
scaleX: screenRect.width / pathObj.width!, scaleX: screenRect.width / pathObj.width!,
scaleY: screenRect.height / pathObj.height!, scaleY: screenRect.height / pathObj.height!,
}); });
@ -111,6 +135,8 @@ export const DrawTool: ToolHandler = {
pathObj = new fabric.Path('M 0 0', { pathObj = new fabric.Path('M 0 0', {
left: screenRect.x, left: screenRect.x,
top: screenRect.y, top: screenRect.y,
originX: 'left',
originY: 'top',
width: screenRect.width, width: screenRect.width,
height: screenRect.height, height: screenRect.height,
stroke: props.strokeColor || '#000000', stroke: props.strokeColor || '#000000',
@ -118,17 +144,18 @@ export const DrawTool: ToolHandler = {
}); });
} }
const annotatedPath = pathObj as FabricAnnotationObject;
annotatedPath.id = drawAnn.id;
annotatedPath.annotationType = 'draw';
annotatedPath.annotationProps = drawAnn.props;
pathObj.set({ pathObj.set({
id: drawAnn.id,
annotationType: 'draw',
annotationProps: drawAnn.props,
transparentCorners: false, transparentCorners: false,
cornerColor: '#3b82f6', cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6', cornerStrokeColor: '#3b82f6',
borderColor: '#3b82f6', borderColor: '#3b82f6',
cornerSize: 8, cornerSize: 8,
padding: 5, padding: 5,
} as any); });
canvas.add(pathObj); canvas.add(pathObj);
} }

View file

@ -0,0 +1,120 @@
import * as fabric from 'fabric';
import { v4 as uuidv4 } from 'uuid';
import { useEditorStore } from '../store';
import type { ToolHandler } from '../../../lib/annotations/registry';
import type { Annotation, HighlightAnnotation, HighlightProps } from '../../../lib/annotations/types';
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords';
type PointerEvent = fabric.TPointerEventInfo;
type Draft = { object: fabric.Rect; start: { x: number; y: number } };
const drafts = new WeakMap<fabric.Canvas, Draft>();
function point(event: PointerEvent, canvas: fabric.Canvas) {
return event.scenePoint ?? canvas.getScenePoint(event.e);
}
function configure(object: fabric.Rect) {
object.set({
originX: 'left',
originY: 'top',
fill: '#facc15',
opacity: 0.35,
stroke: undefined,
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
borderColor: '#3b82f6',
cornerSize: 8,
});
}
export const HighlightTool: ToolHandler = {
name: 'highlight',
onActivate: (canvas) => {
canvas.defaultCursor = 'crosshair';
canvas.selection = false;
},
onDeactivate: (canvas) => {
const draft = drafts.get(canvas);
if (draft) canvas.remove(draft.object);
drafts.delete(canvas);
canvas.defaultCursor = 'default';
},
onPointerDown: (event: PointerEvent, canvas) => {
if (event.target) return;
const start = point(event, canvas);
const object = new fabric.Rect({ left: start.x, top: start.y, width: 0, height: 0 });
configure(object);
drafts.set(canvas, { object, start });
canvas.add(object);
},
onPointerMove: (event: PointerEvent, canvas) => {
const draft = drafts.get(canvas);
if (!draft) return;
const current = point(event, canvas);
draft.object.set({
left: Math.min(draft.start.x, current.x),
top: Math.min(draft.start.y, current.y),
width: Math.abs(current.x - draft.start.x),
height: Math.abs(current.y - draft.start.y),
});
draft.object.setCoords();
canvas.requestRenderAll();
},
onPointerUp: (event: PointerEvent, canvas, vp: ViewportParams, pageNumber) => {
const draft = drafts.get(canvas);
if (!draft) return;
const current = point(event, canvas);
draft.object.set({
left: Math.min(draft.start.x, current.x),
top: Math.min(draft.start.y, current.y),
width: Math.abs(current.x - draft.start.x),
height: Math.abs(current.y - draft.start.y),
});
draft.object.setCoords();
const bounds = draft.object.getBoundingRect();
drafts.delete(canvas);
if (bounds.width < 2 || bounds.height < 2) {
canvas.remove(draft.object);
return;
}
const id = uuidv4();
draft.object.set({ id });
const props: HighlightProps = { color: '#facc15', opacity: 0.35 };
useEditorStore.getState().addAnnotation({
id,
page: pageNumber,
type: 'highlight',
rect: screenRectToPdf(
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
vp,
),
rotation: 0,
z: 0,
props,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as HighlightAnnotation);
canvas.setActiveObject(draft.object);
canvas.requestRenderAll();
},
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'highlight') return;
const highlight = annotation as HighlightAnnotation;
const rect = pdfRectToScreen(highlight.rect, vp);
const object = new fabric.Rect({
left: rect.x,
top: rect.y,
width: rect.width,
height: rect.height,
});
configure(object);
object.set({
id: highlight.id,
fill: highlight.props.color,
opacity: highlight.props.opacity,
});
canvas.add(object);
},
};

View file

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

View file

@ -0,0 +1,210 @@
import * as fabric from 'fabric';
import { v4 as uuidv4 } from 'uuid';
import { useEditorStore } from '../store';
import type { ToolHandler } from '../../../lib/annotations/registry';
import type { Annotation, ShapeAnnotation, ShapeProps } from '../../../lib/annotations/types';
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords';
type PointerEvent = fabric.TPointerEventInfo;
type Draft = { object: fabric.FabricObject; start: { x: number; y: number }; kind: ShapeProps['kind'] };
const drafts = new WeakMap<fabric.Canvas, Draft>();
function pointer(event: PointerEvent, canvas: fabric.Canvas) {
return event.scenePoint ?? canvas.getScenePoint(event.e);
}
function style(object: fabric.FabricObject) {
object.set({
originX: 'left',
originY: 'top',
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
borderColor: '#3b82f6',
cornerSize: 8,
});
}
function arrowGeometry(start: { x: number; y: number }, end: { x: number; y: number }) {
const angle = Math.atan2(end.y - start.y, end.x - start.x);
const length = Math.min(12, Math.max(5, Math.hypot(end.x - start.x, end.y - start.y) * 0.2));
const left = {
x: end.x - length * Math.cos(angle - Math.PI / 6),
y: end.y - length * Math.sin(angle - Math.PI / 6),
};
const right = {
x: end.x - length * Math.cos(angle + Math.PI / 6),
y: end.y - length * Math.sin(angle + Math.PI / 6),
};
const minX = Math.min(start.x, end.x, left.x, right.x);
const minY = Math.min(start.y, end.y, left.y, right.y);
const point = (value: { x: number; y: number }) => `${value.x - minX} ${value.y - minY}`;
return {
left: minX,
top: minY,
path: `M ${point(start)} L ${point(end)} M ${point(end)} L ${point(left)} M ${point(end)} L ${point(right)}`,
};
}
function createArrow(start: { x: number; y: number }, end: { x: number; y: number }) {
const geometry = arrowGeometry(start, end);
return new fabric.Path(geometry.path, {
left: geometry.left,
top: geometry.top,
fill: 'transparent',
stroke: '#111827',
strokeWidth: 2,
originX: 'left',
originY: 'top',
});
}
function createObject(kind: ShapeProps['kind'], start: { x: number; y: number }) {
const common = { left: start.x, top: start.y, fill: 'transparent', stroke: '#111827', strokeWidth: 2 };
if (kind === 'ellipse') return new fabric.Ellipse({ ...common, rx: 0, ry: 0 });
if (kind === 'arrow') return createArrow(start, start);
if (kind === 'line') return new fabric.Line([0, 0, 0, 0], common);
return new fabric.Rect({ ...common, width: 0, height: 0 });
}
function updateObject(draft: Draft, current: { x: number; y: number }, canvas: fabric.Canvas) {
const { object, start, kind } = draft;
if (kind === 'arrow') {
const replacement = createArrow(start, current);
style(replacement);
canvas.remove(object);
canvas.add(replacement);
draft.object = replacement;
return;
}
const width = current.x - start.x;
const height = current.y - start.y;
if (kind === 'line') {
object.set({ x2: width, y2: height });
} else {
object.set({
left: Math.min(start.x, current.x),
top: Math.min(start.y, current.y),
...(kind === 'ellipse'
? { rx: Math.abs(width) / 2, ry: Math.abs(height) / 2 }
: { width: Math.abs(width), height: Math.abs(height) }),
});
}
object.setCoords();
}
export const ShapeTool: ToolHandler = {
name: 'shape',
onActivate: (canvas) => {
canvas.defaultCursor = 'crosshair';
canvas.selection = false;
},
onDeactivate: (canvas) => {
const draft = drafts.get(canvas);
if (draft) canvas.remove(draft.object);
drafts.delete(canvas);
canvas.defaultCursor = 'default';
},
onPointerDown: (event: PointerEvent, canvas) => {
if (event.target) return;
const start = pointer(event, canvas);
const kind = useEditorStore.getState().activeShapeKind;
const object = createObject(kind, start);
style(object);
drafts.set(canvas, { object, start, kind });
canvas.add(object);
},
onPointerMove: (event: PointerEvent, canvas) => {
const draft = drafts.get(canvas);
if (!draft) return;
updateObject(draft, pointer(event, canvas), canvas);
canvas.requestRenderAll();
},
onPointerUp: (event: PointerEvent, canvas, vp: ViewportParams, pageNumber) => {
const draft = drafts.get(canvas);
if (!draft) return;
updateObject(draft, pointer(event, canvas), canvas);
const bounds = draft.object.getBoundingRect();
drafts.delete(canvas);
if (bounds.width < 2 && bounds.height < 2) {
canvas.remove(draft.object);
return;
}
const id = uuidv4();
draft.object.set({ id });
const props: ShapeProps = {
kind: draft.kind,
strokeColor: '#111827',
fillColor: 'transparent',
strokeWidth: 2,
};
if (draft.kind === 'line' || draft.kind === 'arrow') {
const width = Math.max(bounds.width, 1);
const height = Math.max(bounds.height, 1);
const current = pointer(event, canvas);
props.start = [
(draft.start.x - bounds.left) / width,
(draft.start.y - bounds.top) / height,
];
props.end = [
(current.x - bounds.left) / width,
(current.y - bounds.top) / height,
];
}
useEditorStore.getState().addAnnotation({
id,
page: pageNumber,
type: 'shape',
rect: screenRectToPdf(
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
vp,
),
rotation: 0,
z: 0,
props,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as ShapeAnnotation);
canvas.setActiveObject(draft.object);
canvas.requestRenderAll();
},
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'shape') return;
const shape = annotation as ShapeAnnotation;
const rect = pdfRectToScreen(shape.rect, vp);
const props = shape.props;
const common = {
fill: props.fillColor === 'transparent' ? 'transparent' : props.fillColor,
stroke: props.strokeColor,
strokeWidth: props.strokeWidth,
};
let object: fabric.FabricObject;
if (props.kind === 'ellipse') {
object = new fabric.Ellipse({ ...common, left: rect.x, top: rect.y, rx: rect.width / 2, ry: rect.height / 2 });
} else if (props.kind === 'line') {
const start = props.start ?? [0, 0];
const end = props.end ?? [1, 1];
const startPoint = { x: rect.x + start[0] * rect.width, y: rect.y + start[1] * rect.height };
const endPoint = { x: rect.x + end[0] * rect.width, y: rect.y + end[1] * rect.height };
const left = Math.min(startPoint.x, endPoint.x);
const top = Math.min(startPoint.y, endPoint.y);
object = new fabric.Line(
[startPoint.x - left, startPoint.y - top, endPoint.x - left, endPoint.y - top],
{ ...common, left, top },
);
} else if (props.kind === 'arrow') {
const start = props.start ?? [0, 0];
const end = props.end ?? [1, 1];
const startPoint = { x: rect.x + start[0] * rect.width, y: rect.y + start[1] * rect.height };
const endPoint = { x: rect.x + end[0] * rect.width, y: rect.y + end[1] * rect.height };
object = createArrow(startPoint, endPoint);
object.set(common);
} else {
object = new fabric.Rect({ ...common, left: rect.x, top: rect.y, width: rect.width, height: rect.height });
}
style(object);
object.set({ id: shape.id });
canvas.add(object);
},
};

View file

@ -17,6 +17,10 @@ const FONTS = [
'Caveat' 'Caveat'
]; ];
interface AssetUploadResponse {
ref: string;
}
export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) { export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) {
const [tab, setTab] = useState<'type' | 'draw'>('type'); const [tab, setTab] = useState<'type' | 'draw'>('type');
const [text, setText] = useState('John Doe'); const [text, setText] = useState('John Doe');
@ -116,7 +120,7 @@ export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', blob, 'signature.png'); formData.append('file', blob, 'signature.png');
const data = await api.upload<any>(`/documents/${documentId}/assets`, formData); const data = await api.upload<AssetUploadResponse>(`/documents/${documentId}/assets`, formData);
onConfirm({ onConfirm({
mode: 'draw', mode: 'draw',

View file

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

View file

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

View file

@ -3,12 +3,16 @@ import { TextTool } from './TextTool';
import { SignatureTool } from './SignatureTool'; import { SignatureTool } from './SignatureTool';
import { ImageTool } from './ImageTool'; import { ImageTool } from './ImageTool';
import { DrawTool } from './DrawTool'; import { DrawTool } from './DrawTool';
import { HighlightTool } from './HighlightTool';
import { ShapeTool } from './ShapeTool';
// Register all tools here // Register all tools here
registerTool(TextTool); registerTool(TextTool);
registerTool(SignatureTool); registerTool(SignatureTool);
registerTool(ImageTool); registerTool(ImageTool);
registerTool(DrawTool); registerTool(DrawTool);
registerTool(HighlightTool);
registerTool(ShapeTool);
// SelectTool implementation // SelectTool implementation
registerTool({ registerTool({

View file

@ -1,75 +1,135 @@
import { useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { useEditorStore } from './store'; import { api, ApiRequestError } from '../../lib/api/client';
import type { Annotation } from '../../lib/annotations/types'; import type { Annotation } from '../../lib/annotations/types';
import { useEditorStore } from './store';
export function useAutosave() { interface AnnotationResponse {
const { documentId, annotations, setSaveStatus, setAnnotations } = useEditorStore(); data: Annotation[];
updatedAt: string;
}
interface AnnotationUpdateResponse {
updatedAt: string;
}
export function useAutosave(documentId: string | undefined) {
const loadedDocumentId = useRef<string | null>(null);
const skipNextSave = useRef(false);
const timeoutRef = useRef<number | null>(null); const timeoutRef = useRef<number | null>(null);
const isFirstLoad = useRef(true); const saveGeneration = useRef(0);
const setDocumentId = useEditorStore((state) => state.setDocumentId);
const setAnnotations = useEditorStore((state) => state.setAnnotations);
const setAnnotationUpdatedAt = useEditorStore((state) => state.setAnnotationUpdatedAt);
const setSaveStatus = useEditorStore((state) => state.setSaveStatus);
const storeDocumentId = useEditorStore((state) => state.documentId);
const annotations = useEditorStore((state) => state.annotations);
// Initial load
useEffect(() => { useEffect(() => {
if (!documentId) return; saveGeneration.current += 1;
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setDocumentId(documentId ?? null);
loadedDocumentId.current = null;
skipNextSave.current = true;
}, [documentId, setDocumentId]);
useEffect(() => {
if (!documentId || storeDocumentId !== documentId) return;
let active = true; let active = true;
setSaveStatus('loading');
async function load() { async function load() {
try { try {
const res = await fetch(`/api/v1/documents/${documentId}/annotations`); const response = await api.get<AnnotationResponse>(`/documents/${documentId}/annotations`);
if (!res.ok) throw new Error('Failed to load annotations'); if (!active || useEditorStore.getState().documentId !== documentId) return;
const data = await res.json(); skipNextSave.current = true;
setAnnotations(response.data);
if (active) { setAnnotationUpdatedAt(response.updatedAt);
setAnnotations(data.data as Annotation[]); loadedDocumentId.current = documentId;
setSaveStatus('saved'); setSaveStatus('saved');
isFirstLoad.current = false; } catch (error) {
} if (active && useEditorStore.getState().documentId === documentId) {
} catch (err) { console.error('Error loading annotations:', error);
console.error('Error loading annotations:', err);
if (active) setSaveStatus('error');
}
}
load();
return () => { active = false; };
}, [documentId, setAnnotations, setSaveStatus]);
// Debounced save
useEffect(() => {
// Don't save on the initial load!
if (isFirstLoad.current) return;
if (!documentId) return;
setSaveStatus('saving');
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(async () => {
try {
const res = await fetch(`/api/v1/documents/${documentId}/annotations`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
data: annotations
})
});
if (!res.ok) throw new Error('Failed to save');
setSaveStatus('saved');
} catch (err) {
console.error('Error saving annotations:', err);
setSaveStatus('error'); setSaveStatus('error');
} }
}, 500); // 500ms debounce }
}
void load();
return () => {
active = false;
};
}, [documentId, setAnnotationUpdatedAt, setAnnotations, setSaveStatus, storeDocumentId]);
const flush = useCallback(async () => {
if (!documentId || useEditorStore.getState().documentId !== documentId || loadedDocumentId.current !== documentId) {
return false;
}
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
const generation = ++saveGeneration.current;
const snapshot = useEditorStore.getState();
const payload = {
data: snapshot.annotations,
baseUpdatedAt: snapshot.annotationUpdatedAt ?? undefined,
};
setSaveStatus('saving');
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 (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) {
setAnnotationUpdatedAt(response.updatedAt);
setSaveStatus('saved');
}
return true;
} catch (error) {
if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) {
console.error('Error saving annotations:', error);
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);
return () => { return () => {
if (timeoutRef.current) window.clearTimeout(timeoutRef.current); saveGeneration.current += 1;
}; if (timeoutRef.current !== null) {
}, [annotations, documentId, setSaveStatus]); window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [annotations, documentId, flush, setSaveStatus, storeDocumentId]);
useEffect(() => () => {
saveGeneration.current += 1;
if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
}, []);
return { flush };
} }

View file

@ -0,0 +1,236 @@
import { useCallback, useEffect, useState } from 'react';
import { Clock3, RotateCcw, Save, Trash2, X } from 'lucide-react';
import { api } from '../../../lib/api/client';
import type { Annotation } from '../../../lib/annotations/types';
import { useEditorStore } from '../store';
interface VersionMeta {
id: string;
documentId: string;
label: string | null;
kind: 'manual' | 'auto';
createdAt: string;
annotationCount: number;
}
interface VersionListResponse {
items: VersionMeta[];
}
interface AnnotationResponse {
data: Annotation[];
updatedAt: string;
}
interface VersionPanelProps {
documentId: string;
onClose: () => void;
}
function formatVersionDate(value: string) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
export function VersionPanel({ documentId, onClose }: VersionPanelProps) {
const [versions, setVersions] = useState<VersionMeta[]>([]);
const [label, setLabel] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const setAnnotations = useEditorStore((state) => state.setAnnotations);
const setAnnotationUpdatedAt = useEditorStore((state) => state.setAnnotationUpdatedAt);
const loadVersions = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const response = await api.get<VersionListResponse>(`/documents/${documentId}/versions`);
setVersions(response.items);
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to load checkpoints.');
} finally {
setIsLoading(false);
}
}, [documentId]);
useEffect(() => {
const timer = window.setTimeout(() => void loadVersions(), 0);
return () => window.clearTimeout(timer);
}, [loadVersions]);
const createCheckpoint = async () => {
setBusyId('create');
setError(null);
try {
await api.post<VersionMeta>(`/documents/${documentId}/versions`, {
label: label.trim() || null,
kind: 'manual',
});
setLabel('');
await loadVersions();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to create checkpoint.');
} finally {
setBusyId(null);
}
};
const restoreVersion = async (version: VersionMeta) => {
if (!window.confirm(`Restore “${version.label || 'Automatic checkpoint'}”? Your current work will be saved first.`)) {
return;
}
setBusyId(version.id);
setError(null);
try {
const restored = await api.post<{ updatedAt: string }>(
`/documents/${documentId}/versions/${version.id}/restore`,
);
const current = await api.get<AnnotationResponse>(`/documents/${documentId}/annotations`);
setAnnotations(current.data);
setAnnotationUpdatedAt(restored.updatedAt || current.updatedAt);
await loadVersions();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to restore checkpoint.');
} finally {
setBusyId(null);
}
};
const deleteVersion = async (version: VersionMeta) => {
if (!window.confirm('Delete this checkpoint permanently?')) return;
setBusyId(version.id);
setError(null);
try {
await api.delete(`/documents/${documentId}/versions/${version.id}`);
setVersions((current) => current.filter((item) => item.id !== version.id));
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to delete checkpoint.');
} finally {
setBusyId(null);
}
};
return (
<aside className="fixed inset-y-0 right-0 z-[80] flex w-full max-w-md flex-col border-l border-neutral-200 bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
<div>
<h2 className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<Clock3 className="h-5 w-5 text-accent-600" />
Checkpoints
</h2>
<p className="mt-0.5 text-xs text-neutral-500">Recover annotation work without changing the original PDF.</p>
</div>
<button type="button" onClick={onClose} className="rounded-lg p-2 text-neutral-500 hover:bg-neutral-100" aria-label="Close checkpoints">
<X className="h-5 w-5" />
</button>
</div>
<div className="border-b border-neutral-200 bg-neutral-50 px-5 py-4">
<label className="block text-xs font-semibold uppercase tracking-wide text-neutral-500" htmlFor="checkpoint-label">
Save current work
</label>
<div className="mt-2 flex gap-2">
<input
id="checkpoint-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
maxLength={120}
placeholder="Optional label"
className="min-w-0 flex-1 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-accent-500 focus:ring-2 focus:ring-accent-100"
/>
<button
type="button"
onClick={() => void createCheckpoint()}
disabled={busyId !== null}
className="inline-flex items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-2 text-sm font-semibold text-white hover:bg-accent-700 disabled:cursor-wait disabled:opacity-60"
>
<Save className="h-4 w-4" />
Save
</button>
</div>
<button
type="button"
onClick={async () => {
if (!window.confirm('Clear every annotation? A recovery checkpoint will be created first.')) return;
setBusyId('clear');
setError(null);
try {
await api.post(`/documents/${documentId}/versions`, { label: 'Before clear all', kind: 'auto' });
setAnnotations([]);
await loadVersions();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to clear annotations.');
} finally {
setBusyId(null);
}
}}
disabled={busyId !== null}
className="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold text-red-600 hover:text-red-700 disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
Clear all annotations
</button>
</div>
{error && <div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>}
<div className="flex-1 overflow-y-auto px-5 py-4">
{isLoading ? (
<div className="py-12 text-center text-sm text-neutral-500">Loading checkpoints</div>
) : versions.length === 0 ? (
<div className="rounded-xl border border-dashed border-neutral-300 px-5 py-10 text-center">
<Clock3 className="mx-auto h-8 w-8 text-neutral-300" />
<p className="mt-3 text-sm font-medium text-neutral-700">No checkpoints yet</p>
<p className="mt-1 text-xs text-neutral-500">Save one before a major edit or export.</p>
</div>
) : (
<div className="space-y-3">
{versions.map((version) => {
const busy = busyId === version.id;
return (
<div key={version.id} className="rounded-xl border border-neutral-200 bg-white p-3 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-neutral-800">{version.label || 'Automatic checkpoint'}</p>
<p className="mt-1 text-xs text-neutral-500">
{formatVersionDate(version.createdAt)} · {version.annotationCount} annotation{version.annotationCount === 1 ? '' : 's'}
</p>
</div>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${version.kind === 'manual' ? 'bg-accent-100 text-accent-700' : 'bg-neutral-100 text-neutral-500'}`}>
{version.kind}
</span>
</div>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => void restoreVersion(version)}
disabled={busyId !== null}
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 px-2.5 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
>
<RotateCcw className="h-3.5 w-3.5" />
{busy ? 'Restoring…' : 'Restore'}
</button>
{version.kind === 'manual' && (
<button
type="button"
onClick={() => void deleteVersion(version)}
disabled={busyId !== null}
className="rounded-lg p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
aria-label="Delete checkpoint"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</aside>
);
}

View file

@ -6,7 +6,7 @@ import { formatDistanceToNow } from 'date-fns'
import { ContextMenu } from './ContextMenu' import { ContextMenu } from './ContextMenu'
export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => { export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
const { documents, selectedIds, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary() const { documents, selectedIds, isLoading, error, toggleSelection, deleteDocument, restoreDocument, updateDocument } = useLibrary()
const navigate = useNavigate() const navigate = useNavigate()
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null) const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null)
@ -61,6 +61,16 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
isSelected ? 'border-accent-500 shadow-md ring-2 ring-accent-500/50 scale-[1.02]' : 'hover:border-accent-300 dark:hover:border-accent-700' 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 && ( {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"> <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"> <svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -127,9 +137,13 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
} }
} }
}} }}
onDelete={() => deleteDocument(contextMenu.docId)} onDelete={() => {
if (window.confirm('Move this document to Trash?')) void deleteDocument(contextMenu.docId)
}}
onRestore={() => restoreDocument(contextMenu.docId)} onRestore={() => restoreDocument(contextMenu.docId)}
onHardDelete={() => deleteDocument(contextMenu.docId, true)} onHardDelete={() => {
if (window.confirm('Delete this document permanently? This cannot be undone.')) void deleteDocument(contextMenu.docId, true)
}}
/> />
)} )}
</> </>

View file

@ -94,7 +94,9 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar
Restore Restore
</button> </button>
<button <button
onClick={() => bulkDelete(true)} onClick={() => {
if (window.confirm('Delete the selected documents permanently? This cannot be undone.')) void bulkDelete(true)
}}
className="text-sm text-red-600 dark:text-red-400 hover:text-white hover:bg-red-500 font-bold px-3 py-1.5 rounded-full transition-colors ml-1" 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 Delete Forever
@ -123,7 +125,9 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar
{currentTab === 'trash' && ( {currentTab === 'trash' && (
<button <button
onClick={() => emptyTrash()} onClick={() => {
if (window.confirm('Empty Trash permanently? This cannot be undone.')) void emptyTrash()
}}
className="text-sm font-bold text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 transition-colors px-4 py-2 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20" 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 Empty Trash

View file

@ -1,88 +1,74 @@
import { useRef, useState } from 'react' import { useRef, useState, type ReactNode } from 'react';
import { useLibrary } from './useLibrary' import { useLibrary } from './useLibrary';
import { UploadPickerContext, useUploadPicker } from './upload-context';
export const UploadArea = ({ children }: { children: React.ReactNode }) => { export function UploadTrigger({ children, className }: { children: ReactNode; className: string }) {
const { uploadDocument } = useLibrary() const openPicker = useUploadPicker();
const [isDragging, setIsDragging] = useState(false) return <button type="button" onClick={openPicker} className={className}>{children}</button>;
const fileInputRef = useRef<HTMLInputElement>(null)
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
} }
const handleDragLeave = (e: React.DragEvent) => { export const UploadArea = ({ children }: { children: ReactNode }) => {
e.preventDefault() const { uploadDocument } = useLibrary();
e.stopPropagation() const [isDragging, setIsDragging] = useState(false);
setIsDragging(false) const fileInputRef = useRef<HTMLInputElement>(null);
}
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) => { const processFiles = async (files: FileList) => {
// For now, process one by one for (let index = 0; index < files.length; index += 1) {
for (let i = 0; i < files.length; i++) { const file = files[index];
const file = files[i] if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) continue;
if (file.type === 'application/pdf') {
try { try {
await uploadDocument(file) await uploadDocument(file);
} catch (err) { } catch (reason) {
console.error('Failed to upload', file.name, err) console.error('Failed to upload', file.name, reason);
// 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 ( return (
<div <UploadPickerContext.Provider value={() => fileInputRef.current?.click()}>
className="relative min-h-screen" <div className="relative min-h-screen" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={(event) => void handleDrop(event)}>
onDragOver={handleDragOver} <input ref={fileInputRef} type="file" accept="application/pdf" onChange={(event) => void handleFileChange(event)} className="hidden" multiple />
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 && ( {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="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="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="flex flex-col items-center rounded-3xl border border-white/50 bg-white/95 px-10 py-8 shadow-2xl">
<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"> <div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-accent-100 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"> <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" /> <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> </svg>
</div> </div>
<h3 className="text-3xl font-heading font-bold text-gray-900 dark:text-white tracking-tight">Drop PDFs here</h3> <h3 className="text-3xl font-heading font-bold tracking-tight text-neutral-900">Drop PDFs here</h3>
<p className="text-gray-500 dark:text-gray-400 mt-2 font-medium">Release to upload to your library</p> <p className="mt-2 font-medium text-neutral-500">Release to upload to your library</p>
</div> </div>
</div> </div>
)} )}
{children} {children}
</div> </div>
) </UploadPickerContext.Provider>
} );
};

View file

@ -0,0 +1,9 @@
import { createContext, useContext } from 'react';
export const UploadPickerContext = createContext<(() => void) | null>(null);
export function useUploadPicker() {
const openPicker = useContext(UploadPickerContext);
if (!openPicker) throw new Error('useUploadPicker must be used inside UploadArea');
return openPicker;
}

View file

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

View file

@ -6,6 +6,11 @@
@import "@fontsource/plus-jakarta-sans/500.css"; @import "@fontsource/plus-jakarta-sans/500.css";
@import "@fontsource/plus-jakarta-sans/600.css"; @import "@fontsource/plus-jakarta-sans/600.css";
@import "@fontsource/plus-jakarta-sans/700.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"; @import "tailwindcss";

View file

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

View file

@ -1,15 +1,119 @@
import type { components } from '../../types/api'; /**
* Client annotation model.
*
* The API intentionally exposes the working layer as an opaque JSON array so
* newer annotation types can round-trip through an older server. These types
* describe the v1 tools used by this client; the generated API types still
* describe the transport envelope and endpoint shapes.
*/
export type TextProps = components['schemas']['TextProps'] & { highlightColor?: string | null, styles?: Record<string, any> | null }; export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export type TextAnnotation = Omit<components['schemas']['TextAnnotation'], 'props'> & { export interface AnnotationBase {
id: string;
page: number;
type: string;
rect: Rect;
rotation?: number;
z?: number;
createdAt: string;
updatedAt: string;
}
export interface TextProps {
text: string;
fontFamily: string;
fontSize: number;
color: string;
align: 'left' | 'center' | 'right';
bold: boolean;
italic: boolean;
lineHeight: number;
highlightColor?: string | null;
styles?: Record<string, unknown> | null;
}
export interface TextAnnotation extends AnnotationBase {
type: 'text';
props: TextProps; props: TextProps;
}; }
export type DrawAnnotation = components['schemas']['DrawAnnotation'];
export type SignatureAnnotation = components['schemas']['SignatureAnnotation']; export interface DrawProps {
export type ImageAnnotation = components['schemas']['ImageAnnotation']; paths: [number, number][];
export type HighlightAnnotation = components['schemas']['HighlightAnnotation']; svgPath?: string;
export type ShapeAnnotation = components['schemas']['ShapeAnnotation']; 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 | TextAnnotation
@ -17,12 +121,5 @@ export type Annotation =
| SignatureAnnotation | SignatureAnnotation
| ImageAnnotation | ImageAnnotation
| HighlightAnnotation | HighlightAnnotation
| ShapeAnnotation; | ShapeAnnotation
| UnknownAnnotation;
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,6 +30,21 @@ export class ApiRequestError extends Error {
} }
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function toApiError(data: unknown, statusText: string): ApiError {
const record = isRecord(data) ? data : {};
const nested = isRecord(record.error) ? record.error : {};
return {
code: typeof nested.code === 'string' ? nested.code : 'unknown',
message: typeof nested.message === 'string'
? nested.message
: typeof record.detail === 'string' ? record.detail : statusText,
};
}
/** /**
* Internal fetch wrapper with shared behavior. * Internal fetch wrapper with shared behavior.
*/ */
@ -75,23 +90,19 @@ async function apiFetch<T>(
} }
// Parse response // Parse response
let data: any = {} let data: unknown = {}
try { try {
const text = await response.text() const text = await response.text()
if (text) { if (text) {
data = JSON.parse(text) data = JSON.parse(text)
} }
} catch (err) { } catch {
// If it's not JSON, we'll just fall back to the empty object // If it's not JSON, we'll just fall back to the empty object
} }
// Handle error responses // Handle error responses
if (!response.ok) { if (!response.ok) {
const error: ApiError = { throw new ApiRequestError(response.status, toApiError(data, response.statusText))
code: data.error?.code ?? 'unknown',
message: data.error?.message ?? data.detail ?? response.statusText,
}
throw new ApiRequestError(response.status, error)
} }
return data as T return data as T
@ -158,17 +169,15 @@ export const api = {
} }
if (!response.ok) { if (!response.ok) {
let data: any = {} let data: unknown = {}
try { try {
const text = await response.text() const text = await response.text()
if (text) data = JSON.parse(text) if (text) data = JSON.parse(text)
} catch (err) {} } catch {
data = {}
const error: ApiError = {
code: data.error?.code ?? 'unknown',
message: data.error?.message ?? data.detail ?? response.statusText,
} }
throw new ApiRequestError(response.status, error)
throw new ApiRequestError(response.status, toApiError(data, response.statusText))
} }
return response.blob() return response.blob()

View file

@ -92,4 +92,16 @@ describe('Coordinate Transforms', () => {
expect(blS.x).toBeCloseTo(0, 2); expect(blS.x).toBeCloseTo(0, 2);
expect(blS.y).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,19 +5,18 @@ export interface PdfRect { x: number; y: number; width: number; height: number }
export interface ScreenRect { x: number; y: number; width: number; height: number } export interface ScreenRect { x: number; y: number; width: number; height: number }
export interface ViewportParams { 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) rotation: number; // 0, 90, 180, 270 (clockwise)
canonicalWidth: number; // Unrotated CropBox width in PDF points canonicalWidth: number; // Unrotated CropBox width in PDF points
canonicalHeight: number;// Unrotated CropBox height in PDF points canonicalHeight: number;// Unrotated CropBox height in PDF points
dpr?: number; // Device Pixel Ratio (defaults to 1) dpr?: number; // Backing-store density; never part of CSS geometry
}
function getEffectiveScale(vp: ViewportParams): number {
return vp.scale * (vp.dpr || 1);
} }
export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint { export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
const s = getEffectiveScale(vp); // Screen points are CSS pixels. Device-pixel-ratio only controls the PDF.js
// backing canvas; including it here would make Fabric objects overflow the
// CSS overlay on retina displays.
const s = vp.scale;
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp; const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
const { x, y } = p; const { x, y } = p;
@ -33,7 +32,7 @@ export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
} }
export function screenToPdf(p: ScreenPoint, vp: ViewportParams): PdfPoint { export function screenToPdf(p: ScreenPoint, vp: ViewportParams): PdfPoint {
const s = getEffectiveScale(vp); const s = vp.scale;
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp; const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
const sx = p.x / s; const sx = p.x / s;
const sy = p.y / s; const sy = p.y / s;

View file

@ -1,61 +1,113 @@
import { useParams, Link } from 'react-router-dom' import { useCallback, useEffect, useState } from 'react';
import { useEffect } from 'react' import { Link, useParams } from 'react-router-dom';
import { PdfDocument } from '../features/editor/pages/PdfDocument' import { api } from '../lib/api/client';
import { EditorToolbar } from '../features/editor/toolbar/EditorToolbar' import { PdfDocument } from '../features/editor/pages/PdfDocument';
import { useEditorStore } from '../features/editor/store' import { EditorToolbar } from '../features/editor/toolbar/EditorToolbar';
import { useAutosave } from '../features/editor/useAutosave' import { VersionPanel } from '../features/editor/versions/VersionPanel';
import '../features/editor/tools' import { useEditorStore } from '../features/editor/store';
import { useAutosave } from '../features/editor/useAutosave';
import '../features/editor/tools';
interface DocumentSummary {
title: string;
}
/**
* Editor page PDF canvas workspace.
*/
export function EditorPage() { export function EditorPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>();
const { setDocumentId } = useEditorStore() const [title, setTitle] = useState('Untitled document');
useAutosave() const [isExporting, setIsExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const undo = useEditorStore((state) => state.undo);
const redo = useEditorStore((state) => state.redo);
const { flush } = useAutosave(id);
useEffect(() => { useEffect(() => {
if (id) { if (!id) return;
setDocumentId(id) let active = true;
} void api.get<DocumentSummary>(`/documents/${id}`).then((document) => {
}, [id, setDocumentId]) if (active) setTitle(document.title);
}).catch(() => {
if (active) setTitle('Untitled document');
});
return () => {
active = false;
};
}, [id]);
if (!id) return <div>Invalid document ID</div> useEffect(() => {
const handleHistoryShortcut = (event: KeyboardEvent) => {
const target = event.target;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable)) {
return;
}
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') return;
event.preventDefault();
if (event.shiftKey) redo();
else undo();
};
document.addEventListener('keydown', handleHistoryShortcut);
return () => document.removeEventListener('keydown', handleHistoryShortcut);
}, [redo, undo]);
const handleExport = useCallback(async () => {
if (!id) return;
setIsExporting(true);
setExportError(null);
try {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
const saved = await flush();
if (!saved && useEditorStore.getState().saveStatus === 'loading') {
throw new Error('Please wait for the annotation layer to finish loading.');
}
const blob = await api.fetchBlob(`/documents/${id}/export`, {
method: 'POST',
body: JSON.stringify({ flatten: true }),
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = title.toLowerCase().endsWith('.pdf') ? title : `${title}.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (reason) {
const message = reason instanceof Error ? reason.message : 'Unable to export this PDF.';
setExportError(message);
} finally {
setIsExporting(false);
}
}, [flush, id, title]);
if (!id) return <div className="p-8 text-center text-red-600">Invalid document ID.</div>;
return ( return (
<div className="flex h-screen flex-col bg-neutral-100 overflow-hidden"> <div className="flex h-screen flex-col overflow-hidden bg-neutral-100">
{/* Editor toolbar */} <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">
<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 min-w-0 items-center gap-3">
<div className="flex 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">
<Link
to="/"
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-700"
>
Back Back
</Link> </Link>
<span className="text-sm font-medium text-neutral-700"> <div className="h-5 w-px bg-neutral-200" />
Document {id} <span className="max-w-[min(50vw,32rem)] truncate text-sm font-semibold text-neutral-800" title={title}>{title}</span>
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 text-xs text-neutral-400"> {exportError && <span className="max-w-64 truncate text-xs font-medium text-red-600" title={exportError}>{exportError}</span>}
<span className="h-1.5 w-1.5 rounded-full bg-success" /> <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">
Saved {isExporting ? 'Exporting…' : 'Export PDF'}
</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> </button>
</div> </div>
</header> </header>
{/* Main workspace area */} <main className="relative flex-1 overflow-auto">
<div className="flex-1 overflow-auto relative"> <EditorToolbar onExport={() => void handleExport()} onOpenVersions={() => setIsVersionPanelOpen(true)} isExporting={isExporting} />
<EditorToolbar />
<PdfDocument documentId={id} /> <PdfDocument documentId={id} />
</main>
{isVersionPanelOpen && <VersionPanel documentId={id} onClose={() => setIsVersionPanelOpen(false)} />}
</div> </div>
</div> );
)
} }

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { LibraryHeader } from '../features/library/LibraryHeader' import { LibraryHeader } from '../features/library/LibraryHeader'
import { UploadArea } from '../features/library/UploadArea' import { UploadArea, UploadTrigger } from '../features/library/UploadArea'
import { DocumentGrid } from '../features/library/DocumentGrid' import { DocumentGrid } from '../features/library/DocumentGrid'
import { useLibrary } from '../features/library/useLibrary' import { useLibrary } from '../features/library/useLibrary'
@ -38,26 +38,12 @@ export function HomePage() {
<p className="text-base sm:text-lg text-accent-100 font-medium mb-6 leading-relaxed"> <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. Upload any PDF to instantly annotate, sign, and modify it. Drop your files right here to get started.
</p> </p>
<label className="cursor-pointer inline-flex items-center justify-center px-6 py-3 text-base font-bold rounded-full text-accent-900 bg-white hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-white/30 transition-all shadow-xl hover:-translate-y-1 active:translate-y-0"> <UploadTrigger className="cursor-pointer inline-flex items-center justify-center px-6 py-3 text-base font-bold rounded-full text-accent-900 bg-white hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-white/30 transition-all shadow-xl hover:-translate-y-1 active:translate-y-0">
<svg className="w-5 h-5 mr-2 text-accent-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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" /> <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> </svg>
<span>Browse Files</span> <span>Browse Files</span>
<input </UploadTrigger>
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> </div>
{/* Visual Graphic */} {/* Visual Graphic */}

View file

@ -272,6 +272,46 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/documents/{document_id}/annotations": {
parameters: { parameters: {
query?: never; query?: never;
@ -290,6 +330,79 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/health": {
parameters: { parameters: {
query?: never; query?: never;
@ -310,28 +423,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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 type webhooks = Record<string, never>;
export interface components { export interface components {
@ -339,7 +430,9 @@ export interface components {
/** AnnotationStateResponse */ /** AnnotationStateResponse */
AnnotationStateResponse: { AnnotationStateResponse: {
/** Data */ /** Data */
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; data: {
[key: string]: unknown;
}[];
/** /**
* Updatedat * Updatedat
* Format: date-time * Format: date-time
@ -349,7 +442,9 @@ export interface components {
/** AnnotationStateUpdateRequest */ /** AnnotationStateUpdateRequest */
AnnotationStateUpdateRequest: { AnnotationStateUpdateRequest: {
/** Data */ /** Data */
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; data: {
[key: string]: unknown;
}[];
/** Baseupdatedat */ /** Baseupdatedat */
baseUpdatedAt?: string | null; baseUpdatedAt?: string | null;
}; };
@ -368,6 +463,11 @@ export interface components {
/** Loggedin */ /** Loggedin */
loggedIn: boolean; 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 */
Body_upload_document_api_v1_documents_post: { Body_upload_document_api_v1_documents_post: {
/** File */ /** File */
@ -425,402 +525,31 @@ export interface components {
/** In Trash */ /** In Trash */
in_trash?: boolean | null; in_trash?: boolean | null;
}; };
/** DrawAnnotation */ /** ExportRequest */
DrawAnnotation: { ExportRequest: {
/** Versionid */
versionId?: string | null;
/** /**
* Id * Flatten
* Format: uuid * @default true
*/ */
id: string; flatten: boolean;
/** 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 */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
detail?: components["schemas"]["ValidationError"][]; 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 */
LoginRequest: { LoginRequest: {
/** Password */ /** Password */
password: string; password: string;
}; };
/** Rect */
Rect: {
/** X */
x: number;
/** Y */
y: number;
/** Width */
width: number;
/** Height */
height: number;
};
/** SetupRequest */ /** SetupRequest */
SetupRequest: { SetupRequest: {
/** Password */ /** Password */
password: string; 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 */
ValidationError: { ValidationError: {
/** Location */ /** Location */
@ -834,20 +563,58 @@ export interface components {
/** Context */ /** Context */
ctx?: Record<string, never>; ctx?: Record<string, never>;
}; };
/** VerifyCoordsRequest */ /** VersionCreateRequest */
VerifyCoordsRequest: { VersionCreateRequest: {
/** Document Id */ /** Label */
document_id: string; label?: string | null;
/** Page */ /**
page: number; * Kind
/** X */ * @default manual
x: number; * @enum {string}
/** Y */ */
y: number; kind: "manual" | "auto";
/** Width */ };
width: number; /** VersionDataResponse */
/** Height */ VersionDataResponse: {
height: number; /** Data */
data: {
[key: string]: unknown;
}[];
meta: components["schemas"]["VersionMeta"];
};
/** VersionListResponse */
VersionListResponse: {
/** Items */
items: components["schemas"]["VersionMeta"][];
};
/** VersionMeta */
VersionMeta: {
/** Id */
id: string;
/** Documentid */
documentId: string;
/** Label */
label: string | null;
/**
* Kind
* @enum {string}
*/
kind: "manual" | "auto";
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/** Annotationcount */
annotationCount: number;
};
/** VersionRestoreResponse */
VersionRestoreResponse: {
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
}; };
}; };
responses: never; responses: never;
@ -1052,7 +819,7 @@ export interface operations {
}; };
responses: { responses: {
/** @description Successful Response */ /** @description Successful Response */
200: { 201: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
@ -1381,6 +1148,75 @@ export interface operations {
}; };
}; };
}; };
upload_asset_api_v1_documents__id__assets_post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_asset_api_v1_documents__id__assets_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_asset_api_v1_documents__id__assets__ref__get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
ref: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_annotations_api_v1_documents__document_id__annotations_get: { get_annotations_api_v1_documents__document_id__annotations_get: {
parameters: { parameters: {
query?: never; query?: never;
@ -1447,11 +1283,13 @@ export interface operations {
}; };
}; };
}; };
health_check_api_v1_health_get: { list_versions_api_v1_documents__document_id__versions_get: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path?: never; path: {
document_id: string;
};
cookie?: never; cookie?: never;
}; };
requestBody?: never; requestBody?: never;
@ -1462,23 +1300,161 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": { "application/json": components["schemas"]["VersionListResponse"];
[key: string]: string; };
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
}; };
}; };
}; };
}; };
}; create_version_api_v1_documents__document_id__versions_post: {
verify_coords_api_v1_debug_verify_coords_post: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path?: never; path: {
document_id: string;
};
cookie?: never; cookie?: never;
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["VerifyCoordsRequest"]; "application/json": components["schemas"]["VersionCreateRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionMeta"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_version_api_v1_documents__document_id__versions__version_id__get: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionDataResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_version_api_v1_documents__document_id__versions__version_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
restore_version_api_v1_documents__document_id__versions__version_id__restore_post: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionRestoreResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
export_document_api_v1_documents__document_id__export_post: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExportRequest"];
}; };
}; };
responses: { responses: {
@ -1502,4 +1478,26 @@ export interface operations {
}; };
}; };
}; };
health_check_api_v1_health_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
} }

View file

@ -272,6 +272,46 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/documents/{document_id}/annotations": {
parameters: { parameters: {
query?: never; query?: never;
@ -290,6 +330,79 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/health": {
parameters: { parameters: {
query?: never; query?: never;
@ -310,28 +423,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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 type webhooks = Record<string, never>;
export interface components { export interface components {
@ -339,7 +430,9 @@ export interface components {
/** AnnotationStateResponse */ /** AnnotationStateResponse */
AnnotationStateResponse: { AnnotationStateResponse: {
/** Data */ /** Data */
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; data: {
[key: string]: unknown;
}[];
/** /**
* Updatedat * Updatedat
* Format: date-time * Format: date-time
@ -349,7 +442,9 @@ export interface components {
/** AnnotationStateUpdateRequest */ /** AnnotationStateUpdateRequest */
AnnotationStateUpdateRequest: { AnnotationStateUpdateRequest: {
/** Data */ /** Data */
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; data: {
[key: string]: unknown;
}[];
/** Baseupdatedat */ /** Baseupdatedat */
baseUpdatedAt?: string | null; baseUpdatedAt?: string | null;
}; };
@ -368,6 +463,11 @@ export interface components {
/** Loggedin */ /** Loggedin */
loggedIn: boolean; 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 */
Body_upload_document_api_v1_documents_post: { Body_upload_document_api_v1_documents_post: {
/** File */ /** File */
@ -425,396 +525,31 @@ export interface components {
/** In Trash */ /** In Trash */
in_trash?: boolean | null; in_trash?: boolean | null;
}; };
/** DrawAnnotation */ /** ExportRequest */
DrawAnnotation: { ExportRequest: {
/** Versionid */
versionId?: string | null;
/** /**
* Id * Flatten
* Format: uuid * @default true
*/ */
id: string; flatten: boolean;
/** 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 */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
detail?: components["schemas"]["ValidationError"][]; 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 */
LoginRequest: { LoginRequest: {
/** Password */ /** Password */
password: string; password: string;
}; };
/** Rect */
Rect: {
/** X */
x: number;
/** Y */
y: number;
/** Width */
width: number;
/** Height */
height: number;
};
/** SetupRequest */ /** SetupRequest */
SetupRequest: { SetupRequest: {
/** Password */ /** Password */
password: string; 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 */
ValidationError: { ValidationError: {
/** Location */ /** Location */
@ -828,20 +563,58 @@ export interface components {
/** Context */ /** Context */
ctx?: Record<string, never>; ctx?: Record<string, never>;
}; };
/** VerifyCoordsRequest */ /** VersionCreateRequest */
VerifyCoordsRequest: { VersionCreateRequest: {
/** Document Id */ /** Label */
document_id: string; label?: string | null;
/** Page */ /**
page: number; * Kind
/** X */ * @default manual
x: number; * @enum {string}
/** Y */ */
y: number; kind: "manual" | "auto";
/** Width */ };
width: number; /** VersionDataResponse */
/** Height */ VersionDataResponse: {
height: number; /** Data */
data: {
[key: string]: unknown;
}[];
meta: components["schemas"]["VersionMeta"];
};
/** VersionListResponse */
VersionListResponse: {
/** Items */
items: components["schemas"]["VersionMeta"][];
};
/** VersionMeta */
VersionMeta: {
/** Id */
id: string;
/** Documentid */
documentId: string;
/** Label */
label: string | null;
/**
* Kind
* @enum {string}
*/
kind: "manual" | "auto";
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/** Annotationcount */
annotationCount: number;
};
/** VersionRestoreResponse */
VersionRestoreResponse: {
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
}; };
}; };
responses: never; responses: never;
@ -1046,7 +819,7 @@ export interface operations {
}; };
responses: { responses: {
/** @description Successful Response */ /** @description Successful Response */
200: { 201: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
@ -1375,6 +1148,75 @@ export interface operations {
}; };
}; };
}; };
upload_asset_api_v1_documents__id__assets_post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_asset_api_v1_documents__id__assets_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_asset_api_v1_documents__id__assets__ref__get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
ref: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_annotations_api_v1_documents__document_id__annotations_get: { get_annotations_api_v1_documents__document_id__annotations_get: {
parameters: { parameters: {
query?: never; query?: never;
@ -1441,11 +1283,13 @@ export interface operations {
}; };
}; };
}; };
health_check_api_v1_health_get: { list_versions_api_v1_documents__document_id__versions_get: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path?: never; path: {
document_id: string;
};
cookie?: never; cookie?: never;
}; };
requestBody?: never; requestBody?: never;
@ -1456,23 +1300,161 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": { "application/json": components["schemas"]["VersionListResponse"];
[key: string]: string; };
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
}; };
}; };
}; };
}; };
}; create_version_api_v1_documents__document_id__versions_post: {
verify_coords_api_v1_debug_verify_coords_post: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path?: never; path: {
document_id: string;
};
cookie?: never; cookie?: never;
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["VerifyCoordsRequest"]; "application/json": components["schemas"]["VersionCreateRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionMeta"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_version_api_v1_documents__document_id__versions__version_id__get: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionDataResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_version_api_v1_documents__document_id__versions__version_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
restore_version_api_v1_documents__document_id__versions__version_id__restore_post: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionRestoreResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
export_document_api_v1_documents__document_id__export_post: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExportRequest"];
}; };
}; };
responses: { responses: {
@ -1496,4 +1478,26 @@ export interface operations {
}; };
}; };
}; };
health_check_api_v1_health_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
} }

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -104,7 +104,9 @@
"kind": { "type": "string", "enum": ["rect", "ellipse", "line", "arrow"] }, "kind": { "type": "string", "enum": ["rect", "ellipse", "line", "arrow"] },
"strokeColor": { "type": "string", "default": "#000000" }, "strokeColor": { "type": "string", "default": "#000000" },
"fillColor": { "type": "string", "default": "transparent" }, "fillColor": { "type": "string", "default": "transparent" },
"strokeWidth": { "type": "number", "default": 2 } "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 }
}, },
"required": ["kind"] "required": ["kind"]
} }