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.
# 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
@ -40,9 +40,13 @@ COOKIE_SECURE=false
# --------------------------------------------
# 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
# Optional image reference for a pulled Forgejo build.
# Leave unset for local builds; set this before docker compose pull in production.
# PAPERJET_IMAGE=git.elijahkuntz.com/your-user/paperjet:latest
# --------------------------------------------
# Debug
# --------------------------------------------

View file

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

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
**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.
> **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.
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)
- Multi-user collaboration / real-time co-editing
@ -78,8 +78,8 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
| ORM | SQLAlchemy 2.x | With Alembic for migrations |
| Database | SQLite (WAL mode) | Single-user scale; embedded, no extra container |
| Password hashing | Argon2id (`argon2-cffi`) | |
| Web server (frontend) | nginx | Serves the static React build; also the app-internal entry |
| ASGI server (backend) | uvicorn | Behind nginx |
| Web server | nginx | Serves the static React build and proxies /api to loopback uvicorn |
| ASGI server | uvicorn | Runs on loopback inside the same application image |
| Orchestration | Docker Compose | |
| External programs / containers / APIs | **None** | Hard requirement |
@ -98,43 +98,44 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
┌─────────────▼─────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ frontend │ │ backend │ │
│ │ nginx │─────▶│ FastAPI │ │
│ │ serves SPA │ /api │ uvicorn │ │
│ │ proxies /api│ │ PyMuPDF │ │
│ └──────────────┘ │ SQLAlchemy │ │
│ └───────┬────────┘ │
│ │ │
│ ┌──────────▼────────┐ │
│ │ volumes │ │
│ │ pdf_storage/ │ │
│ │ thumbnails/ │ │
│ │ db/app.sqlite │ │
│ └───────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ paperjet image │ │
│ │ nginx :80 ── /api ──▶ uvicorn :8000 │ │
│ │ React SPA FastAPI + PyMuPDF │ │
│ └───────────────────┬──────────────────┘ │
│ │ │
│ ┌──────────▼────────┐ │
│ │ volumes │ │
│ │ pdf_storage/ │ │
│ │ thumbnails/ │ │
│ │ db/app.sqlite │ │
│ └───────────────────┘ │
└────────────────────────────────────────────┘
```
- The `frontend` nginx serves the SPA and reverse-proxies `/api/*` to the `backend`. This means the browser sees a single origin — no CORS complexity, cookies "just work."
- The user's outer reverse proxy points at the `frontend` container's published port.
- The single `paperjet` container serves the SPA and reverse-proxies `/api/*` to its loopback-only FastAPI process. The browser sees a single origin — no CORS complexity, cookies "just work."
- The user's outer reverse proxy points at the published port of the `paperjet` container.
---
## 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/
├── README.md
├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first
├── docker-compose.yml
├── Dockerfile
├── docker-entrypoint.sh
├── .dockerignore
├── .env.example
├── .github/workflows/ci.yml # lint + typecheck + test on push
├── .forgejo/workflows/ci.yml # lint, tests, image build, and publish
├── frontend/
│ ├── Dockerfile # multi-stage: build SPA, serve via nginx
│ ├── nginx.conf # SPA fallback + /api proxy
│ ├── nginx.conf # SPA fallback + loopback /api proxy
│ ├── package.json
│ ├── tsconfig.json # strict: true
│ ├── vite.config.ts
@ -163,7 +164,6 @@ paperjet/
│ └── types/ # generated from backend OpenAPI
├── backend/
│ ├── Dockerfile
│ ├── pyproject.toml
│ ├── alembic/ # migrations
│ └── app/
@ -342,7 +342,7 @@ In-document accidental edits during a single session are also covered by the in-
- The registry is a dict `{type: handler}`; adding a type adds a handler, nothing else.
- Missing handler → log + skip, never crash (§7.3).
- Export accepts an optional `versionId` to export a historical version instead of the working state.
- Fonts: bundle inside the 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)
- `POST /documents/{id}/assets` (multipart) stores a signature PNG or placed image on the `pdf_storage` volume, returns `{ ref }`.
@ -499,29 +499,29 @@ Target the Sejda feel: bright, airy, light mode, generous whitespace, a restrain
## 13. Docker Compose & Deployment
```yaml
# docker-compose.yml (illustrative; agents finalize)
# docker-compose.yml
services:
backend:
build: ./backend
paperjet:
image: ${PAPERJET_IMAGE:-paperjet:local}
build:
context: .
dockerfile: Dockerfile
environment:
- SECRET_KEY=${SECRET_KEY}
- MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200}
- TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30}
- AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30}
- COOKIE_SECURE=${COOKIE_SECURE:-false} # false for direct-HTTP dev; set true in production (behind the HTTPS proxy)
PAPERJET_SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting PaperJet}
PAPERJET_MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-200}
PAPERJET_TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
PAPERJET_AUTO_VERSION_RETENTION_DAYS: ${AUTO_VERSION_RETENTION_DAYS:-30}
PAPERJET_COOKIE_SECURE: ${COOKIE_SECURE:-false}
PAPERJET_DATABASE_PATH: /data/db/app.sqlite
PAPERJET_PDF_STORAGE_PATH: /data/pdfs
PAPERJET_THUMBNAILS_PATH: /data/thumbnails
PAPERJET_DEBUG: ${DEBUG:-false}
volumes:
- pdf_storage:/data/pdfs
- thumbnails:/data/thumbnails
- db:/data/db
expose:
- "8000"
restart: unless-stopped
frontend:
build: ./frontend
depends_on: [backend]
ports:
- "${HTTP_PORT:-8080}:80" # user's outer reverse proxy targets this
- "${HTTP_PORT:-4982}:80"
restart: unless-stopped
volumes:
@ -530,10 +530,10 @@ volumes:
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.
- Backend Dockerfile installs Python deps and the bundled fonts; PyMuPDF comes from its wheel (no system packages, no external programs).
- Frontend Dockerfile is multi-stage: Node builds the SPA, the result is copied into an nginx image.
- The root Dockerfile builds the SPA with Node, installs production Python dependencies and bundled fonts, and copies both into one nginx/Python runtime image.
- docker-entrypoint.sh supervises nginx and loopback uvicorn so a failed child process stops the container cleanly.
- All persistent state lives in named volumes mountable on the user's Unraid array. Document the volume → host-path mapping for backups.
---
@ -563,7 +563,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the
3. **One coordinate-transform module.** All space conversions go through `lib/coords.ts`. Never compute transforms inline in a component or duplicate the logic. Canonical space is always PDF points, top-left origin (§8).
4. **Store coordinates only in canonical space.** Never persist screen pixels.
5. **Types are generated, not hand-written.** After any backend schema change, regenerate the OpenAPI types for the frontend. Keep the TS annotation union and the Pydantic models in sync with `shared/annotation-schema.json`.
6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the 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.
8. **Security code is review-gated.** Do not modify auth, session, hashing, upload validation, or path handling without explicitly calling it out for review. Never log secrets or password material.
9. **Tests are required for the risk areas:** the coordinate module (round-trip + cross-engine), the export renderer per handler, auth flows, and upload validation. A feature touching these isn't done until its tests pass.
@ -580,7 +580,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the
Sequenced so each phase is independently testable and the riskiest correctness work (coordinates, export) is validated early.
**Phase 0 — Scaffolding**
Monorepo, 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**
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 alembic import context
from sqlalchemy import engine_from_config, pool
from alembic import context
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
if config.config_file_name is not None:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ All settings are driven by environment variables (see .env.example).
"""
from pathlib import Path
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.
"""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from collections.abc import Generator
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 app.config import settings
@ -16,6 +15,7 @@ from app.config import settings
class Base(DeclarativeBase):
"""SQLAlchemy declarative base for all models."""
pass
@ -49,10 +49,10 @@ engine = create_db_engine()
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
def get_db() -> Session:
def get_db() -> Generator[Session, None, None]:
"""FastAPI dependency that yields a database session."""
db = SessionLocal()
try:
yield db # type: ignore[misc]
yield db
finally:
db.close()

View file

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

View file

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

View file

@ -1,15 +1,19 @@
"""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.orm import Mapped, mapped_column, relationship
from app.db import Base
if TYPE_CHECKING:
from app.models.document import Document
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
return datetime.now(UTC).isoformat()
class AnnotationState(Base):

View file

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

View file

@ -1,6 +1,6 @@
"""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.orm import Mapped, mapped_column
@ -22,10 +22,10 @@ class Settings(Base):
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
created_at: Mapped[str] = mapped_column(
Text,
default=lambda: datetime.now(timezone.utc).isoformat(),
default=lambda: datetime.now(UTC).isoformat(),
)
updated_at: Mapped[str] = mapped_column(
Text,
default=lambda: datetime.now(timezone.utc).isoformat(),
onupdate=lambda: datetime.now(timezone.utc).isoformat(),
default=lambda: datetime.now(UTC).isoformat(),
onupdate=lambda: datetime.now(UTC).isoformat(),
)

View file

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

View file

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

View file

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

View file

@ -1,7 +1,8 @@
"""Pydantic schemas for Document APIs."""
from pydantic import BaseModel
from typing import Optional
class DocumentMeta(BaseModel):
id: str
@ -12,7 +13,7 @@ class DocumentMeta(BaseModel):
in_trash: bool
created_at: str
updated_at: str
deleted_at: Optional[str]
deleted_at: str | None
model_config = {"from_attributes": True}
@ -27,5 +28,5 @@ class BulkRestoreRequest(BaseModel):
ids: list[str]
class DocumentUpdateRequest(BaseModel):
title: Optional[str] = None
in_trash: Optional[bool] = None
title: str | None = 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 pymupdf
from pathlib import Path
from fastapi import UploadFile, HTTPException
import pymupdf
from fastapi import HTTPException, UploadFile
from app.config import settings
def validate_pdf(filepath: Path) -> None:
"""Validate PDF magic bytes and PyMuPDF openability."""
with open(filepath, "rb") as f:
header = f.read(5)
if header != b"%PDF-":
_COPY_CHUNK_SIZE = 1024 * 1024
def validate_pdf(filepath: Path) -> int:
"""Validate a PDF's magic bytes and return its page count."""
with filepath.open("rb") as file:
if file.read(5) != b"%PDF-":
raise ValueError("Not a valid PDF file (missing magic bytes)")
try:
doc = pymupdf.open(filepath)
doc.close()
except Exception as e:
raise ValueError(f"Failed to open PDF with PyMuPDF: {e}")
with pymupdf.open(filepath) as document:
page_count = len(document)
except Exception as err:
raise ValueError(f"Failed to open PDF with PyMuPDF: {err}") from err
if page_count == 0:
raise ValueError("PDF does not contain any pages")
return page_count
def safe_filename(filename: str | None) -> str:
"""Return a display-safe basename without allowing path components."""
name = Path(filename or "Untitled.pdf").name.strip()
return name or "Untitled.pdf"
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())
settings.PDF_STORAGE_PATH.mkdir(parents=True, exist_ok=True)
filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
max_bytes = settings.MAX_UPLOAD_MB * 1024 * 1024
total_bytes = 0
try:
with open(filepath, "wb") as f:
shutil.copyfileobj(upload_file.file, f)
except Exception as e:
if filepath.exists():
filepath.unlink()
raise HTTPException(status_code=500, detail="Failed to save file")
with filepath.open("wb") as destination:
while chunk := upload_file.file.read(_COPY_CHUNK_SIZE):
total_bytes += len(chunk)
if total_bytes > max_bytes:
raise HTTPException(status_code=413, detail="File too large")
destination.write(chunk)
except HTTPException:
filepath.unlink(missing_ok=True)
raise
except Exception as err:
filepath.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="Failed to save file") from err
page_count = 0
try:
doc = pymupdf.open(filepath)
page_count = len(doc)
doc.close()
except Exception as e:
filepath.unlink()
raise HTTPException(status_code=400, detail=f"Failed to open PDF with PyMuPDF: {e}")
size = filepath.stat().st_size
return file_id, size, page_count
page_count = validate_pdf(filepath)
except ValueError as err:
filepath.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail=str(err)) from err
return file_id, total_bytes, page_count
def delete_pdf_file(file_id: str) -> None:
"""Delete a PDF file from storage."""
filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
if filepath.exists():
filepath.unlink()
filepath.unlink(missing_ok=True)

View file

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

View file

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

View file

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

View file

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

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)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```sh
npm ci
npm run dev # Vite with /api proxied to localhost:8000
npm test -- --run
npm run typecheck
npm run lint
npm run build
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
The editor renders PDF pages with PDF.js and uses Fabric.js for the annotation
layer. Annotation state is persisted through the backend API; the browser does
not write PDF bytes directly.

View file

@ -2,10 +2,12 @@ server {
listen 80;
server_name _;
access_log /dev/stdout;
error_log /dev/stderr warn;
include /etc/nginx/mime.types;
types {
application/javascript mjs;
application/wasm wasm;
}
# Match MAX_UPLOAD_MB raise in both this and the outer reverse proxy
@ -25,9 +27,9 @@ server {
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/ {
proxy_pass http://backend:8000;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View file

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

View file

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

View file

@ -16,6 +16,17 @@ interface AuthState {
clearError: () => void
}
function errorMessage(reason: unknown, fallback: string) {
if (reason instanceof Error && reason.message) return reason.message;
if (typeof reason === 'object' && reason !== null && 'error' in reason) {
const error = reason.error;
if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') {
return error.message;
}
}
return fallback;
}
export const useAuth = create<AuthState>((set) => ({
isInitialized: false,
setupRequired: false,
@ -33,9 +44,9 @@ export const useAuth = create<AuthState>((set) => ({
isInitialized: true,
isLoading: false
})
} catch (err: any) {
} catch (err: unknown) {
set({
error: err.error?.message || err.message || 'Failed to initialize',
error: errorMessage(err, 'Failed to initialize'),
isLoading: false,
isInitialized: true
})
@ -47,9 +58,9 @@ export const useAuth = create<AuthState>((set) => ({
set({ isLoading: true, error: null })
await authApi.login(data)
set({ loggedIn: true, isLoading: false })
} catch (err: any) {
} catch (err: unknown) {
set({
error: err.error?.message || err.message || 'Failed to login',
error: errorMessage(err, 'Failed to login'),
isLoading: false
})
throw err
@ -61,9 +72,9 @@ export const useAuth = create<AuthState>((set) => ({
set({ isLoading: true, error: null })
await authApi.setup(data)
set({ setupRequired: false, loggedIn: true, isLoading: false })
} catch (err: any) {
} catch (err: unknown) {
set({
error: err.error?.message || err.message || 'Failed to setup',
error: errorMessage(err, 'Failed to setup'),
isLoading: false
})
throw err
@ -76,9 +87,9 @@ export const useAuth = create<AuthState>((set) => ({
await authApi.logout()
set({ loggedIn: false, isLoading: false })
window.location.href = '/login'
} catch (err: any) {
} catch (err: unknown) {
set({
error: err.error?.message || err.message || 'Failed to logout',
error: errorMessage(err, 'Failed to logout'),
isLoading: false
})
}

View file

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

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

View file

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

View file

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

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

View file

@ -1,161 +1,162 @@
import { useRef, useState } from 'react';
import { useEditorStore } from '../store';
import { useRef, useState, type ChangeEvent } from 'react';
import {
Download,
History,
Highlighter,
ImagePlus,
Minus,
MousePointer2,
PenLine,
Redo2,
Signature,
Square,
Type,
Undo2,
ZoomIn,
} from 'lucide-react';
import { SignatureModal } from '../tools/SignatureModal';
import { api } from '../../../lib/api/client';
import { useEditorStore } from '../store';
import type { SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types';
export function EditorToolbar() {
const {
activeTool,
setActiveTool,
saveStatus,
zoom,
interface EditorToolbarProps {
onExport: () => void;
onOpenVersions: () => void;
isExporting: boolean;
}
interface AssetUploadResponse {
ref: string;
}
const toolButton = 'inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold transition-colors';
const inactive = 'text-neutral-600 hover:bg-neutral-100';
const active = 'bg-accent-100 text-accent-700';
export function EditorToolbar({ onExport, onOpenVersions, isExporting }: EditorToolbarProps) {
const {
activeTool,
setActiveTool,
activeShapeKind,
setActiveShapeKind,
saveStatus,
zoom,
setZoom,
isSignatureModalOpen,
setIsSignatureModalOpen,
setPendingSignatureProps,
setPendingImageRef,
documentId
documentId,
past,
future,
undo,
redo,
} = useEditorStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const handleZoomIn = () => setZoom(Math.min(zoom + 0.25, 3));
const handleZoomOut = () => setZoom(Math.max(zoom - 0.25, 0.5));
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !documentId) return;
setIsUploadingImage(true);
try {
const formData = new FormData();
formData.append('file', file);
const data = await api.upload<any>(`/documents/${documentId}/assets`, formData);
// We don't have natural width/height immediately, we'll let the tool figure it out or pass placeholders
// A better way is to read image locally to get dimensions, but we can also just let fabric.Image.fromURL do it.
// We'll pass 0 for now and let the tool set scale to max-width.
const data = await api.upload<AssetUploadResponse>(`/documents/${documentId}/assets`, formData);
setPendingImageRef({ ref: data.ref, width: 0, height: 0 });
setActiveTool('image');
} catch (err) {
console.error(err);
alert('Failed to upload image');
} catch (reason) {
console.error('Failed to upload image', reason);
window.alert(reason instanceof Error ? reason.message : 'Failed to upload image.');
} finally {
setIsUploadingImage(false);
// Reset input
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSignatureConfirm = (props: any) => {
const handleSignatureConfirm = (props: SignatureDrawProps | SignatureTypeProps) => {
setPendingSignatureProps(props);
setIsSignatureModalOpen(false);
setActiveTool('signature');
};
const isActive = (name: string) => activeTool === name ? active : inactive;
return (
<>
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-neutral-200 px-2 py-2 flex items-center gap-2 z-50">
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'select'
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => setActiveTool('select')}
>
Select
<div className="sticky top-3 z-30 mx-auto flex w-fit max-w-[calc(100%-2rem)] flex-wrap items-center justify-center gap-1 rounded-xl border border-neutral-200 bg-white/95 px-2 py-2 shadow-lg backdrop-blur">
<button type="button" className={`${toolButton} ${isActive('select')}`} onClick={() => setActiveTool('select')} title="Select and move annotations">
<MousePointer2 className="h-4 w-4" /> Select
</button>
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'text'
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => setActiveTool('text')}
>
Add Text
<button type="button" className={`${toolButton} ${isActive('text')}`} onClick={() => setActiveTool('text')} title="Add text">
<Type className="h-4 w-4" /> Text
</button>
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'draw'
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => setActiveTool('draw')}
>
Draw
<button type="button" className={`${toolButton} ${isActive('draw')}`} onClick={() => setActiveTool('draw')} title="Draw freehand">
<PenLine className="h-4 w-4" /> Draw
</button>
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'signature'
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => setIsSignatureModalOpen(true)}
>
Signature
<button type="button" className={`${toolButton} ${isActive('highlight')}`} onClick={() => setActiveTool('highlight')} title="Highlight an area">
<Highlighter className="h-4 w-4" /> Highlight
</button>
<button
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTool === 'image' || isUploadingImage
? 'bg-blue-100 text-blue-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingImage}
>
{isUploadingImage ? 'Uploading...' : 'Image'}
<button type="button" className={`${toolButton} ${isActive('shape')}`} onClick={() => setActiveTool('shape')} title="Draw a shape">
<Square className="h-4 w-4" /> Shape
</button>
<input
type="file"
ref={fileInputRef}
accept="image/*, image/webp"
className="hidden"
onChange={handleImageUpload}
/>
<div className="w-px h-6 bg-neutral-200 mx-2" />
<div className="flex items-center gap-1">
<button
onClick={handleZoomOut}
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
{activeTool === 'shape' && (
<select
value={activeShapeKind}
onChange={(event) => setActiveShapeKind(event.target.value as typeof activeShapeKind)}
className="h-8 rounded-md border border-neutral-200 bg-neutral-50 px-1.5 text-xs font-medium text-neutral-700 outline-none focus:border-accent-500"
aria-label="Shape type"
>
-
</button>
<span className="text-sm font-medium text-neutral-600 w-12 text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
>
+
</button>
</div>
<option value="rect">Rectangle</option>
<option value="ellipse">Ellipse</option>
<option value="line">Line</option>
<option value="arrow">Arrow</option>
</select>
)}
<button type="button" className={`${toolButton} ${isActive('signature')}`} onClick={() => setIsSignatureModalOpen(true)} title="Add a signature">
<Signature className="h-4 w-4" /> Sign
</button>
<button type="button" className={`${toolButton} ${activeTool === 'image' || isUploadingImage ? active : inactive}`} onClick={() => fileInputRef.current?.click()} disabled={isUploadingImage} title="Add an image">
<ImagePlus className="h-4 w-4" /> {isUploadingImage ? 'Uploading…' : 'Image'}
</button>
<input ref={fileInputRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" className="hidden" onChange={(event) => void handleImageUpload(event)} />
<div className="w-px h-6 bg-neutral-200 mx-2" />
<span className="mx-1 h-6 w-px bg-neutral-200" />
<button type="button" className={`${toolButton} ${past.length ? inactive : 'cursor-not-allowed text-neutral-300'}`} onClick={undo} disabled={!past.length} title="Undo (Ctrl/Cmd+Z)">
<Undo2 className="h-4 w-4" />
</button>
<button type="button" className={`${toolButton} ${future.length ? inactive : 'cursor-not-allowed text-neutral-300'}`} onClick={redo} disabled={!future.length} title="Redo (Ctrl/Cmd+Shift+Z)">
<Redo2 className="h-4 w-4" />
</button>
<div className="text-xs font-medium text-neutral-400 min-w-16 text-center">
{saveStatus === 'saving' && 'Saving...'}
<span className="mx-1 h-6 w-px bg-neutral-200" />
<button type="button" className={`${toolButton} ${inactive}`} onClick={() => setZoom(zoom - 0.25)} disabled={zoom <= 0.5} title="Zoom out">
<Minus className="h-4 w-4" />
</button>
<span className="min-w-12 text-center text-xs font-semibold text-neutral-600">{Math.round(zoom * 100)}%</span>
<button type="button" className={`${toolButton} ${inactive}`} onClick={() => setZoom(zoom + 0.25)} disabled={zoom >= 3} title="Zoom in">
<ZoomIn className="h-4 w-4" />
</button>
<span className="mx-1 h-6 w-px bg-neutral-200" />
<button type="button" className={`${toolButton} ${inactive}`} onClick={onOpenVersions} title="Open checkpoints">
<History className="h-4 w-4" /> History
</button>
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md bg-accent-600 px-3 text-xs font-bold text-white transition-colors hover:bg-accent-700 disabled:cursor-wait disabled:opacity-60" onClick={onExport} disabled={isExporting} title="Export flattened PDF">
<Download className="h-4 w-4" /> {isExporting ? 'Exporting…' : 'Export'}
</button>
<span className="ml-1 min-w-16 text-center text-[11px] font-semibold text-neutral-500">
{saveStatus === 'loading' && 'Loading…'}
{saveStatus === 'saving' && 'Saving…'}
{saveStatus === 'saved' && 'Saved'}
{saveStatus === 'error' && <span className="text-red-500">Error saving</span>}
{saveStatus === 'idle' && ''}
</div>
{saveStatus === 'error' && <span className="text-red-600">Save failed</span>}
</span>
</div>
{isSignatureModalOpen && (
<SignatureModal
<SignatureModal
onClose={() => setIsSignatureModalOpen(false)}
onConfirm={handleSignatureConfirm}
/>

View file

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

View file

@ -6,6 +6,12 @@ import type { Annotation, DrawAnnotation, DrawProps } from '../../../lib/annotat
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
import type { ViewportParams } from '../../../lib/coords';
type FabricAnnotationObject = fabric.FabricObject & {
id?: string;
annotationType?: string;
annotationProps?: DrawProps;
};
export const DrawTool: ToolHandler = {
name: 'draw',
@ -21,7 +27,7 @@ export const DrawTool: ToolHandler = {
canvas.isDrawingMode = false;
},
onPathCreated: (e: any, _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
onPathCreated: (e: fabric.CanvasEvents['path:created'], _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
const pathObj = e.path as fabric.Path;
pathObj.set({
@ -33,27 +39,16 @@ export const DrawTool: ToolHandler = {
padding: 5,
});
(pathObj as any).id = uuidv4();
(pathObj as any).annotationType = 'draw';
const annotatedPath = pathObj as FabricAnnotationObject;
annotatedPath.id = uuidv4();
annotatedPath.annotationType = 'draw';
// The path data is stored in `pathObj.path` array of commands, e.g. [['M', x, y], ['Q', cx, cy, x, y]]
// We can serialize it via pathObj.toObject().path or complexPathToString(pathObj.path)
// fabric 7 has toObject().path or you can just rely on the object's serialization
// Convert paths to string representation
// Keep the SVG path for faithful browser re-rendering and also store a
// canonical point list for the server export renderer.
const pathStr = Array.isArray(pathObj.path)
? pathObj.path.map(cmd => cmd.join(' ')).join(' ')
: pathObj.path;
const props: DrawProps = {
paths: [], // we ignore points array for now and use svgPath
svgPath: pathStr as string,
strokeColor: pathObj.stroke as string,
strokeWidth: pathObj.strokeWidth,
opacity: pathObj.opacity
};
(pathObj as any).annotationProps = props;
const bounds = pathObj.getBoundingRect();
const pdfRect = screenRectToPdf({
x: bounds.left,
@ -61,9 +56,36 @@ export const DrawTool: ToolHandler = {
width: bounds.width,
height: bounds.height
}, vp);
const rawPoints = Array.isArray(pathObj.path)
? pathObj.path.flatMap((command) => {
const values = command.slice(1).filter((value): value is number => typeof value === 'number');
const points: [number, number][] = [];
for (let index = 0; index + 1 < values.length; index += 2) {
points.push([values[index], values[index + 1]]);
}
return points;
})
: [];
const minX = rawPoints.length ? Math.min(...rawPoints.map(([x]) => x)) : 0;
const maxX = rawPoints.length ? Math.max(...rawPoints.map(([x]) => x)) : 1;
const minY = rawPoints.length ? Math.min(...rawPoints.map(([, y]) => y)) : 0;
const maxY = rawPoints.length ? Math.max(...rawPoints.map(([, y]) => y)) : 1;
const sourceWidth = Math.max(maxX - minX, 1e-6);
const sourceHeight = Math.max(maxY - minY, 1e-6);
const props: DrawProps = {
paths: rawPoints.map(([x, y]) => [
((x - minX) / sourceWidth) * pdfRect.width,
((y - minY) / sourceHeight) * pdfRect.height,
]),
svgPath: pathStr as string,
strokeColor: pathObj.stroke as string,
strokeWidth: pathObj.strokeWidth,
opacity: pathObj.opacity,
};
annotatedPath.annotationProps = props;
const annotation: DrawAnnotation = {
id: (pathObj as any).id,
id: annotatedPath.id as string,
page: pageNumber,
type: 'draw',
rect: pdfRect,
@ -81,7 +103,7 @@ export const DrawTool: ToolHandler = {
},
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
if (annotation.type !== 'draw') return null;
if (annotation.type !== 'draw') return;
const drawAnn = annotation as DrawAnnotation;
const props = drawAnn.props as DrawProps;
const screenRect = pdfRectToScreen(drawAnn.rect, vp);
@ -103,6 +125,8 @@ export const DrawTool: ToolHandler = {
pathObj.set({
left: screenRect.x,
top: screenRect.y,
originX: 'left',
originY: 'top',
scaleX: screenRect.width / pathObj.width!,
scaleY: screenRect.height / pathObj.height!,
});
@ -111,6 +135,8 @@ export const DrawTool: ToolHandler = {
pathObj = new fabric.Path('M 0 0', {
left: screenRect.x,
top: screenRect.y,
originX: 'left',
originY: 'top',
width: screenRect.width,
height: screenRect.height,
stroke: props.strokeColor || '#000000',
@ -118,17 +144,18 @@ export const DrawTool: ToolHandler = {
});
}
const annotatedPath = pathObj as FabricAnnotationObject;
annotatedPath.id = drawAnn.id;
annotatedPath.annotationType = 'draw';
annotatedPath.annotationProps = drawAnn.props;
pathObj.set({
id: drawAnn.id,
annotationType: 'draw',
annotationProps: drawAnn.props,
transparentCorners: false,
cornerColor: '#3b82f6',
cornerStrokeColor: '#3b82f6',
borderColor: '#3b82f6',
cornerSize: 8,
padding: 5,
} as any);
});
canvas.add(pathObj);
}

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

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

View file

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

View file

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

View file

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

View file

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

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'
export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
const { documents, selectedIds, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary()
const { documents, selectedIds, isLoading, error, toggleSelection, deleteDocument, restoreDocument, updateDocument } = useLibrary()
const navigate = useNavigate()
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null)
@ -61,6 +61,16 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
isSelected ? 'border-accent-500 shadow-md ring-2 ring-accent-500/50 scale-[1.02]' : 'hover:border-accent-300 dark:hover:border-accent-700'
}`}
>
<label className="absolute right-3 top-3 z-20 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border border-white/80 bg-white/90 shadow-sm backdrop-blur transition-opacity group-hover:opacity-100 sm:opacity-0">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelection(doc.id)}
onClick={(event) => event.stopPropagation()}
className="h-4 w-4 rounded border-neutral-300 text-accent-600 focus:ring-accent-500"
aria-label={`Select ${doc.title}`}
/>
</label>
{isSelected && (
<div className="absolute top-3 left-3 z-20 w-6 h-6 bg-accent-500 rounded-full flex items-center justify-center shadow-md ring-2 ring-white">
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -127,9 +137,13 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
}
}
}}
onDelete={() => deleteDocument(contextMenu.docId)}
onDelete={() => {
if (window.confirm('Move this document to Trash?')) void deleteDocument(contextMenu.docId)
}}
onRestore={() => restoreDocument(contextMenu.docId)}
onHardDelete={() => deleteDocument(contextMenu.docId, true)}
onHardDelete={() => {
if (window.confirm('Delete this document permanently? This cannot be undone.')) void deleteDocument(contextMenu.docId, true)
}}
/>
)}
</>

View file

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

View file

@ -1,88 +1,74 @@
import { useRef, useState } from 'react'
import { useLibrary } from './useLibrary'
import { useRef, useState, type ReactNode } from 'react';
import { useLibrary } from './useLibrary';
import { UploadPickerContext, useUploadPicker } from './upload-context';
export const UploadArea = ({ children }: { children: React.ReactNode }) => {
const { uploadDocument } = useLibrary()
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
export function UploadTrigger({ children, className }: { children: ReactNode; className: string }) {
const openPicker = useUploadPicker();
return <button type="button" onClick={openPicker} className={className}>{children}</button>;
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await processFiles(e.dataTransfer.files)
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
await processFiles(e.target.files)
// Reset input so the same file can be selected again
e.target.value = ''
}
}
export const UploadArea = ({ children }: { children: ReactNode }) => {
const { uploadDocument } = useLibrary();
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const processFiles = async (files: FileList) => {
// For now, process one by one
for (let i = 0; i < files.length; i++) {
const file = files[i]
if (file.type === 'application/pdf') {
try {
await uploadDocument(file)
} catch (err) {
console.error('Failed to upload', file.name, err)
// Could show toast notification here
}
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) continue;
try {
await uploadDocument(file);
} catch (reason) {
console.error('Failed to upload', file.name, reason);
}
}
}
};
const handleDragOver = (event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = async (event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (event.dataTransfer.files.length > 0) await processFiles(event.dataTransfer.files);
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) await processFiles(event.target.files);
event.target.value = '';
};
return (
<div
className="relative min-h-screen"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
accept="application/pdf"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
multiple
/>
{/* Invisible overlay that appears when dragging to prevent flickering */}
{isDragging && (
<div className="absolute inset-0 z-50 bg-accent-500/10 dark:bg-accent-900/20 backdrop-blur-md border-4 border-dashed border-accent-400 dark:border-accent-500 rounded-3xl m-4 flex items-center justify-center transition-all pointer-events-none">
<div className="bg-white/95 dark:bg-gray-800/95 px-10 py-8 rounded-3xl shadow-2xl shadow-accent-500/20 flex flex-col items-center transform scale-105 transition-transform duration-300 border border-white/50">
<div className="w-20 h-20 bg-gradient-to-br from-accent-100 to-accent-200 dark:from-accent-800 dark:to-accent-900 rounded-full flex items-center justify-center mb-6 shadow-inner">
<svg className="w-10 h-10 text-accent-600 dark:text-accent-300 animate-bounce" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<h3 className="text-3xl font-heading font-bold text-gray-900 dark:text-white tracking-tight">Drop PDFs here</h3>
<p className="text-gray-500 dark:text-gray-400 mt-2 font-medium">Release to upload to your library</p>
</div>
</div>
)}
<UploadPickerContext.Provider value={() => fileInputRef.current?.click()}>
<div className="relative min-h-screen" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={(event) => void handleDrop(event)}>
<input ref={fileInputRef} type="file" accept="application/pdf" onChange={(event) => void handleFileChange(event)} className="hidden" multiple />
{children}
</div>
)
}
{isDragging && (
<div className="pointer-events-none absolute inset-0 z-50 m-4 flex items-center justify-center rounded-3xl border-4 border-dashed border-accent-400 bg-accent-500/10 backdrop-blur-md">
<div className="flex flex-col items-center rounded-3xl border border-white/50 bg-white/95 px-10 py-8 shadow-2xl">
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-accent-100 shadow-inner">
<svg className="h-10 w-10 animate-bounce text-accent-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<h3 className="text-3xl font-heading font-bold tracking-tight text-neutral-900">Drop PDFs here</h3>
<p className="mt-2 font-medium text-neutral-500">Release to upload to your library</p>
</div>
</div>
)}
{children}
</div>
</UploadPickerContext.Provider>
);
};

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>
}
function errorMessage(reason: unknown, fallback = 'Something went wrong.') {
if (reason instanceof Error && reason.message) return reason.message;
if (typeof reason === 'object' && reason !== null && 'error' in reason) {
const error = reason.error;
if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') {
return error.message;
}
}
return fallback;
}
export const useLibrary = create<LibraryState>((set, get) => ({
documents: [],
total: 0,
@ -56,8 +67,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
try {
const data = await libraryApi.getDocuments(query)
set({ documents: data.items, total: data.total, isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
}
},
@ -66,8 +77,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
try {
const data = await libraryApi.getTrash()
set({ documents: data.items, total: data.total, isLoading: false })
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
}
},
@ -81,8 +92,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
isLoading: false
}))
return newDoc
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
throw err
}
},
@ -97,8 +108,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
isLoading: false
}))
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
throw err
}
},
@ -113,8 +124,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
isLoading: false
}))
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
throw err
}
},
@ -138,8 +149,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
selectedIds: new Set(),
isLoading: false
}))
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
throw err
}
},
@ -157,8 +168,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
selectedIds: new Set(),
isLoading: false
}))
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
throw err
}
},
@ -173,8 +184,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
selectedIds: new Set(),
isLoading: false
})
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
throw err
}
},
@ -187,8 +198,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
documents: state.documents.map(d => d.id === id ? updatedDoc : d),
isLoading: false
}))
} catch (err: any) {
set({ error: err.error?.message || err.message, isLoading: false })
} catch (err: unknown) {
set({ error: errorMessage(err), isLoading: false })
throw err
}
}

View file

@ -6,6 +6,11 @@
@import "@fontsource/plus-jakarta-sans/500.css";
@import "@fontsource/plus-jakarta-sans/600.css";
@import "@fontsource/plus-jakarta-sans/700.css";
@import "@fontsource/allura/400.css";
@import "@fontsource/caveat/400.css";
@import "@fontsource/dancing-script/400.css";
@import "@fontsource/great-vibes/400.css";
@import "@fontsource/sacramento/400.css";
@import "tailwindcss";

View file

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

View file

@ -1,28 +1,125 @@
import type { components } from '../../types/api';
/**
* Client annotation model.
*
* The API intentionally exposes the working layer as an opaque JSON array so
* newer annotation types can round-trip through an older server. These types
* describe the v1 tools used by this client; the generated API types still
* describe the transport envelope and endpoint shapes.
*/
export type TextProps = components['schemas']['TextProps'] & { highlightColor?: string | null, styles?: Record<string, any> | null };
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export type TextAnnotation = Omit<components['schemas']['TextAnnotation'], 'props'> & {
export interface AnnotationBase {
id: string;
page: number;
type: string;
rect: Rect;
rotation?: number;
z?: number;
createdAt: string;
updatedAt: string;
}
export interface TextProps {
text: string;
fontFamily: string;
fontSize: number;
color: string;
align: 'left' | 'center' | 'right';
bold: boolean;
italic: boolean;
lineHeight: number;
highlightColor?: string | null;
styles?: Record<string, unknown> | null;
}
export interface TextAnnotation extends AnnotationBase {
type: 'text';
props: TextProps;
};
export type DrawAnnotation = components['schemas']['DrawAnnotation'];
export type SignatureAnnotation = components['schemas']['SignatureAnnotation'];
export type ImageAnnotation = components['schemas']['ImageAnnotation'];
export type HighlightAnnotation = components['schemas']['HighlightAnnotation'];
export type ShapeAnnotation = components['schemas']['ShapeAnnotation'];
}
export type Annotation =
export interface DrawProps {
paths: [number, number][];
svgPath?: string;
strokeColor: string;
strokeWidth: number;
opacity: number;
}
export interface DrawAnnotation extends AnnotationBase {
type: 'draw';
props: DrawProps;
}
export interface SignatureDrawProps {
mode: 'draw';
ref: string;
strokeColor?: string;
}
export interface SignatureTypeProps {
mode: 'type';
text: string;
fontFamily: string;
color: string;
}
export interface SignatureAnnotation extends AnnotationBase {
type: 'signature';
props: SignatureDrawProps | SignatureTypeProps;
}
export interface ImageProps {
ref: string;
naturalWidth: number;
naturalHeight: number;
}
export interface ImageAnnotation extends AnnotationBase {
type: 'image';
props: ImageProps;
}
export interface HighlightProps {
color: string;
opacity: number;
}
export interface HighlightAnnotation extends AnnotationBase {
type: 'highlight';
props: HighlightProps;
}
export interface ShapeProps {
kind: 'rect' | 'ellipse' | 'line' | 'arrow';
strokeColor: string;
fillColor: string;
strokeWidth: number;
/** Normalized endpoints, retained for line/arrow direction. */
start?: [number, number];
end?: [number, number];
}
export interface ShapeAnnotation extends AnnotationBase {
type: 'shape';
props: ShapeProps;
}
export interface UnknownAnnotation extends AnnotationBase {
type: string;
props: Record<string, unknown>;
}
export type Annotation =
| TextAnnotation
| DrawAnnotation
| SignatureAnnotation
| ImageAnnotation
| HighlightAnnotation
| ShapeAnnotation;
export type Rect = components['schemas']['Rect'];
export type DrawProps = components['schemas']['DrawProps'] & { svgPath?: string };
export type SignatureDrawProps = components['schemas']['SignatureDrawProps'];
export type SignatureTypeProps = components['schemas']['SignatureTypeProps'];
export type ImageProps = components['schemas']['ImageProps'];
export type HighlightProps = components['schemas']['HighlightProps'];
export type ShapeProps = components['schemas']['ShapeProps'];
| ShapeAnnotation
| UnknownAnnotation;

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

View file

@ -92,4 +92,16 @@ describe('Coordinate Transforms', () => {
expect(blS.x).toBeCloseTo(0, 2);
expect(blS.y).toBeCloseTo(0, 2);
});
it('keeps CSS geometry independent from device pixel ratio', () => {
const standard = pdfToScreen(
{ x: 120, y: 180 },
{ scale: 1.5, rotation: 0, canonicalWidth: W, canonicalHeight: H, dpr: 1 },
);
const retina = pdfToScreen(
{ x: 120, y: 180 },
{ scale: 1.5, rotation: 0, canonicalWidth: W, canonicalHeight: H, dpr: 2 },
);
expect(retina).toEqual(standard);
});
});

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

View file

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

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { LibraryHeader } from '../features/library/LibraryHeader'
import { UploadArea } from '../features/library/UploadArea'
import { UploadArea, UploadTrigger } from '../features/library/UploadArea'
import { DocumentGrid } from '../features/library/DocumentGrid'
import { useLibrary } from '../features/library/useLibrary'
@ -38,26 +38,12 @@ export function HomePage() {
<p className="text-base sm:text-lg text-accent-100 font-medium mb-6 leading-relaxed">
Upload any PDF to instantly annotate, sign, and modify it. Drop your files right here to get started.
</p>
<label className="cursor-pointer inline-flex items-center justify-center px-6 py-3 text-base font-bold rounded-full text-accent-900 bg-white hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-white/30 transition-all shadow-xl hover:-translate-y-1 active:translate-y-0">
<UploadTrigger className="cursor-pointer inline-flex items-center justify-center px-6 py-3 text-base font-bold rounded-full text-accent-900 bg-white hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-white/30 transition-all shadow-xl hover:-translate-y-1 active:translate-y-0">
<svg className="w-5 h-5 mr-2 text-accent-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span>Browse Files</span>
<input
type="file"
accept="application/pdf"
multiple
className="hidden"
onChange={(e) => {
const uploader = document.querySelector('input[type="file"][multiple]') as HTMLInputElement
if (uploader && uploader !== e.target) {
uploader.files = e.target.files
const event = new Event('change', { bubbles: true })
uploader.dispatchEvent(event)
}
}}
/>
</label>
</UploadTrigger>
</div>
{/* Visual Graphic */}

View file

@ -272,6 +272,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/documents/{id}/assets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Upload Asset
* @description Upload a PNG, JPEG, GIF, or WebP annotation asset.
*/
post: operations["upload_asset_api_v1_documents__id__assets_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/documents/{id}/assets/{ref}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Asset
* @description Serve an annotation asset to an authenticated session.
*/
get: operations["get_asset_api_v1_documents__id__assets__ref__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/documents/{document_id}/annotations": {
parameters: {
query?: never;
@ -290,6 +330,79 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/documents/{document_id}/versions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Versions */
get: operations["list_versions_api_v1_documents__document_id__versions_get"];
put?: never;
/** Create Version */
post: operations["create_version_api_v1_documents__document_id__versions_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/documents/{document_id}/versions/{version_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Version */
get: operations["get_version_api_v1_documents__document_id__versions__version_id__get"];
put?: never;
post?: never;
/** Delete Version */
delete: operations["delete_version_api_v1_documents__document_id__versions__version_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/documents/{document_id}/versions/{version_id}/restore": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Restore Version */
post: operations["restore_version_api_v1_documents__document_id__versions__version_id__restore_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/documents/{document_id}/export": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Export Document
* @description Export the working layer or a selected version as a downloadable PDF.
*/
post: operations["export_document_api_v1_documents__document_id__export_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/health": {
parameters: {
query?: never;
@ -310,28 +423,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/debug/verify-coords": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Verify Coords
* @description Test endpoint for cross-engine coordinate verification.
* Takes a canonical rect, draws it on the PDF using PyMuPDF,
* and returns the flattened PDF.
*/
post: operations["verify_coords_api_v1_debug_verify_coords_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@ -339,7 +430,9 @@ export interface components {
/** AnnotationStateResponse */
AnnotationStateResponse: {
/** Data */
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
data: {
[key: string]: unknown;
}[];
/**
* Updatedat
* Format: date-time
@ -349,7 +442,9 @@ export interface components {
/** AnnotationStateUpdateRequest */
AnnotationStateUpdateRequest: {
/** Data */
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
data: {
[key: string]: unknown;
}[];
/** Baseupdatedat */
baseUpdatedAt?: string | null;
};
@ -368,6 +463,11 @@ export interface components {
/** Loggedin */
loggedIn: boolean;
};
/** Body_upload_asset_api_v1_documents__id__assets_post */
Body_upload_asset_api_v1_documents__id__assets_post: {
/** File */
file: string;
};
/** Body_upload_document_api_v1_documents_post */
Body_upload_document_api_v1_documents_post: {
/** File */
@ -425,402 +525,31 @@ export interface components {
/** In Trash */
in_trash?: boolean | null;
};
/** DrawAnnotation */
DrawAnnotation: {
/** ExportRequest */
ExportRequest: {
/** Versionid */
versionId?: string | null;
/**
* Id
* Format: uuid
* Flatten
* @default true
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "draw";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
props: components["schemas"]["DrawProps"];
};
/** DrawProps */
DrawProps: {
/** Paths */
paths: [
number,
number
][];
/**
* Strokecolor
* @default #000000
*/
strokeColor: string;
/**
* Strokewidth
* @default 2
*/
strokeWidth: number;
/**
* Opacity
* @default 1
*/
opacity: number;
flatten: boolean;
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** HighlightAnnotation */
HighlightAnnotation: {
/**
* Id
* Format: uuid
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "highlight";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
props: components["schemas"]["HighlightProps"];
};
/** HighlightProps */
HighlightProps: {
/**
* Color
* @default #FFEB3B
*/
color: string;
/**
* Opacity
* @default 0.3
*/
opacity: number;
};
/** ImageAnnotation */
ImageAnnotation: {
/**
* Id
* Format: uuid
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "image";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
props: components["schemas"]["ImageProps"];
};
/** ImageProps */
ImageProps: {
/** Ref */
ref: string;
/** Naturalwidth */
naturalWidth: number;
/** Naturalheight */
naturalHeight: number;
};
/** LoginRequest */
LoginRequest: {
/** Password */
password: string;
};
/** Rect */
Rect: {
/** X */
x: number;
/** Y */
y: number;
/** Width */
width: number;
/** Height */
height: number;
};
/** SetupRequest */
SetupRequest: {
/** Password */
password: string;
};
/** ShapeAnnotation */
ShapeAnnotation: {
/**
* Id
* Format: uuid
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "shape";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
props: components["schemas"]["ShapeProps"];
};
/** ShapeProps */
ShapeProps: {
/**
* Kind
* @enum {string}
*/
kind: "rect" | "ellipse" | "line" | "arrow";
/**
* Strokecolor
* @default #000000
*/
strokeColor: string;
/**
* Fillcolor
* @default transparent
*/
fillColor: string;
/**
* Strokewidth
* @default 2
*/
strokeWidth: number;
};
/** SignatureAnnotation */
SignatureAnnotation: {
/**
* Id
* Format: uuid
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "signature";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
/** Props */
props: components["schemas"]["SignatureDrawProps"] | components["schemas"]["SignatureTypeProps"];
};
/** SignatureDrawProps */
SignatureDrawProps: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
mode: "draw";
/** Ref */
ref: string;
/**
* Strokecolor
* @default #000000
*/
strokeColor: string;
};
/** SignatureTypeProps */
SignatureTypeProps: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
mode: "type";
/** Text */
text: string;
/** Fontfamily */
fontFamily: string;
/**
* Color
* @default #000000
*/
color: string;
};
/** TextAnnotation */
TextAnnotation: {
/**
* Id
* Format: uuid
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "text";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
props: components["schemas"]["TextProps"];
};
/** TextProps */
TextProps: {
/** Text */
text: string;
/**
* Fontfamily
* @default Liberation Sans
*/
fontFamily: string;
/**
* Fontsize
* @default 14
*/
fontSize: number;
/**
* Color
* @default #000000
*/
color: string;
/**
* Align
* @default left
* @enum {string}
*/
align: "left" | "center" | "right";
/**
* Bold
* @default false
*/
bold: boolean;
/**
* Italic
* @default false
*/
italic: boolean;
/**
* Lineheight
* @default 1.2
*/
lineHeight: number;
/** Highlightcolor */
highlightColor?: string | null;
/** Styles */
styles?: {
[key: string]: unknown;
} | null;
};
/** ValidationError */
ValidationError: {
/** Location */
@ -834,20 +563,58 @@ export interface components {
/** Context */
ctx?: Record<string, never>;
};
/** VerifyCoordsRequest */
VerifyCoordsRequest: {
/** Document Id */
document_id: string;
/** Page */
page: number;
/** X */
x: number;
/** Y */
y: number;
/** Width */
width: number;
/** Height */
height: number;
/** VersionCreateRequest */
VersionCreateRequest: {
/** Label */
label?: string | null;
/**
* Kind
* @default manual
* @enum {string}
*/
kind: "manual" | "auto";
};
/** VersionDataResponse */
VersionDataResponse: {
/** Data */
data: {
[key: string]: unknown;
}[];
meta: components["schemas"]["VersionMeta"];
};
/** VersionListResponse */
VersionListResponse: {
/** Items */
items: components["schemas"]["VersionMeta"][];
};
/** VersionMeta */
VersionMeta: {
/** Id */
id: string;
/** Documentid */
documentId: string;
/** Label */
label: string | null;
/**
* Kind
* @enum {string}
*/
kind: "manual" | "auto";
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/** Annotationcount */
annotationCount: number;
};
/** VersionRestoreResponse */
VersionRestoreResponse: {
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
};
};
responses: never;
@ -1052,7 +819,7 @@ export interface operations {
};
responses: {
/** @description Successful Response */
200: {
201: {
headers: {
[name: string]: unknown;
};
@ -1381,6 +1148,75 @@ export interface operations {
};
};
};
upload_asset_api_v1_documents__id__assets_post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_asset_api_v1_documents__id__assets_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_asset_api_v1_documents__id__assets__ref__get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
ref: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_annotations_api_v1_documents__document_id__annotations_get: {
parameters: {
query?: never;
@ -1447,11 +1283,13 @@ export interface operations {
};
};
};
health_check_api_v1_health_get: {
list_versions_api_v1_documents__document_id__versions_get: {
parameters: {
query?: never;
header?: never;
path?: never;
path: {
document_id: string;
};
cookie?: never;
};
requestBody?: never;
@ -1462,23 +1300,161 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
"application/json": components["schemas"]["VersionListResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
verify_coords_api_v1_debug_verify_coords_post: {
create_version_api_v1_documents__document_id__versions_post: {
parameters: {
query?: never;
header?: never;
path?: never;
path: {
document_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["VerifyCoordsRequest"];
"application/json": components["schemas"]["VersionCreateRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionMeta"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_version_api_v1_documents__document_id__versions__version_id__get: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionDataResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_version_api_v1_documents__document_id__versions__version_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
restore_version_api_v1_documents__document_id__versions__version_id__restore_post: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionRestoreResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
export_document_api_v1_documents__document_id__export_post: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExportRequest"];
};
};
responses: {
@ -1502,4 +1478,26 @@ export interface operations {
};
};
};
health_check_api_v1_health_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
}

View file

@ -272,6 +272,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/documents/{id}/assets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Upload Asset
* @description Upload a PNG, JPEG, GIF, or WebP annotation asset.
*/
post: operations["upload_asset_api_v1_documents__id__assets_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/documents/{id}/assets/{ref}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Asset
* @description Serve an annotation asset to an authenticated session.
*/
get: operations["get_asset_api_v1_documents__id__assets__ref__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/documents/{document_id}/annotations": {
parameters: {
query?: never;
@ -290,6 +330,79 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/documents/{document_id}/versions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Versions */
get: operations["list_versions_api_v1_documents__document_id__versions_get"];
put?: never;
/** Create Version */
post: operations["create_version_api_v1_documents__document_id__versions_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/documents/{document_id}/versions/{version_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Version */
get: operations["get_version_api_v1_documents__document_id__versions__version_id__get"];
put?: never;
post?: never;
/** Delete Version */
delete: operations["delete_version_api_v1_documents__document_id__versions__version_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/documents/{document_id}/versions/{version_id}/restore": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Restore Version */
post: operations["restore_version_api_v1_documents__document_id__versions__version_id__restore_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/documents/{document_id}/export": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Export Document
* @description Export the working layer or a selected version as a downloadable PDF.
*/
post: operations["export_document_api_v1_documents__document_id__export_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/health": {
parameters: {
query?: never;
@ -310,28 +423,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/debug/verify-coords": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Verify Coords
* @description Test endpoint for cross-engine coordinate verification.
* Takes a canonical rect, draws it on the PDF using PyMuPDF,
* and returns the flattened PDF.
*/
post: operations["verify_coords_api_v1_debug_verify_coords_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@ -339,7 +430,9 @@ export interface components {
/** AnnotationStateResponse */
AnnotationStateResponse: {
/** Data */
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
data: {
[key: string]: unknown;
}[];
/**
* Updatedat
* Format: date-time
@ -349,7 +442,9 @@ export interface components {
/** AnnotationStateUpdateRequest */
AnnotationStateUpdateRequest: {
/** Data */
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
data: {
[key: string]: unknown;
}[];
/** Baseupdatedat */
baseUpdatedAt?: string | null;
};
@ -368,6 +463,11 @@ export interface components {
/** Loggedin */
loggedIn: boolean;
};
/** Body_upload_asset_api_v1_documents__id__assets_post */
Body_upload_asset_api_v1_documents__id__assets_post: {
/** File */
file: string;
};
/** Body_upload_document_api_v1_documents_post */
Body_upload_document_api_v1_documents_post: {
/** File */
@ -425,396 +525,31 @@ export interface components {
/** In Trash */
in_trash?: boolean | null;
};
/** DrawAnnotation */
DrawAnnotation: {
/** ExportRequest */
ExportRequest: {
/** Versionid */
versionId?: string | null;
/**
* Id
* Format: uuid
* Flatten
* @default true
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "draw";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
props: components["schemas"]["DrawProps"];
};
/** DrawProps */
DrawProps: {
/** Paths */
paths: [
number,
number
][];
/**
* Strokecolor
* @default #000000
*/
strokeColor: string;
/**
* Strokewidth
* @default 2
*/
strokeWidth: number;
/**
* Opacity
* @default 1
*/
opacity: number;
flatten: boolean;
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** HighlightAnnotation */
HighlightAnnotation: {
/**
* Id
* Format: uuid
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "highlight";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
props: components["schemas"]["HighlightProps"];
};
/** HighlightProps */
HighlightProps: {
/**
* Color
* @default #FFEB3B
*/
color: string;
/**
* Opacity
* @default 0.3
*/
opacity: number;
};
/** ImageAnnotation */
ImageAnnotation: {
/**
* Id
* Format: uuid
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "image";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
props: components["schemas"]["ImageProps"];
};
/** ImageProps */
ImageProps: {
/** Ref */
ref: string;
/** Naturalwidth */
naturalWidth: number;
/** Naturalheight */
naturalHeight: number;
};
/** LoginRequest */
LoginRequest: {
/** Password */
password: string;
};
/** Rect */
Rect: {
/** X */
x: number;
/** Y */
y: number;
/** Width */
width: number;
/** Height */
height: number;
};
/** SetupRequest */
SetupRequest: {
/** Password */
password: string;
};
/** ShapeAnnotation */
ShapeAnnotation: {
/**
* Id
* Format: uuid
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "shape";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
props: components["schemas"]["ShapeProps"];
};
/** ShapeProps */
ShapeProps: {
/**
* Kind
* @enum {string}
*/
kind: "rect" | "ellipse" | "line" | "arrow";
/**
* Strokecolor
* @default #000000
*/
strokeColor: string;
/**
* Fillcolor
* @default transparent
*/
fillColor: string;
/**
* Strokewidth
* @default 2
*/
strokeWidth: number;
};
/** SignatureAnnotation */
SignatureAnnotation: {
/**
* Id
* Format: uuid
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "signature";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
/** Props */
props: components["schemas"]["SignatureDrawProps"] | components["schemas"]["SignatureTypeProps"];
};
/** SignatureDrawProps */
SignatureDrawProps: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
mode: "draw";
/** Ref */
ref: string;
/**
* Strokecolor
* @default #000000
*/
strokeColor: string;
};
/** SignatureTypeProps */
SignatureTypeProps: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
mode: "type";
/** Text */
text: string;
/** Fontfamily */
fontFamily: string;
/**
* Color
* @default #000000
*/
color: string;
};
/** TextAnnotation */
TextAnnotation: {
/**
* Id
* Format: uuid
*/
id: string;
/** Page */
page: number;
/**
* Type
* @constant
*/
type: "text";
rect: components["schemas"]["Rect"];
/**
* Rotation
* @default 0
*/
rotation: number;
/**
* Z
* @default 0
*/
z: number;
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
props: components["schemas"]["TextProps"];
};
/** TextProps */
TextProps: {
/** Text */
text: string;
/**
* Fontfamily
* @default Liberation Sans
*/
fontFamily: string;
/**
* Fontsize
* @default 14
*/
fontSize: number;
/**
* Color
* @default #000000
*/
color: string;
/**
* Align
* @default left
* @enum {string}
*/
align: "left" | "center" | "right";
/**
* Bold
* @default false
*/
bold: boolean;
/**
* Italic
* @default false
*/
italic: boolean;
/**
* Lineheight
* @default 1.2
*/
lineHeight: number;
};
/** ValidationError */
ValidationError: {
/** Location */
@ -828,20 +563,58 @@ export interface components {
/** Context */
ctx?: Record<string, never>;
};
/** VerifyCoordsRequest */
VerifyCoordsRequest: {
/** Document Id */
document_id: string;
/** Page */
page: number;
/** X */
x: number;
/** Y */
y: number;
/** Width */
width: number;
/** Height */
height: number;
/** VersionCreateRequest */
VersionCreateRequest: {
/** Label */
label?: string | null;
/**
* Kind
* @default manual
* @enum {string}
*/
kind: "manual" | "auto";
};
/** VersionDataResponse */
VersionDataResponse: {
/** Data */
data: {
[key: string]: unknown;
}[];
meta: components["schemas"]["VersionMeta"];
};
/** VersionListResponse */
VersionListResponse: {
/** Items */
items: components["schemas"]["VersionMeta"][];
};
/** VersionMeta */
VersionMeta: {
/** Id */
id: string;
/** Documentid */
documentId: string;
/** Label */
label: string | null;
/**
* Kind
* @enum {string}
*/
kind: "manual" | "auto";
/**
* Createdat
* Format: date-time
*/
createdAt: string;
/** Annotationcount */
annotationCount: number;
};
/** VersionRestoreResponse */
VersionRestoreResponse: {
/**
* Updatedat
* Format: date-time
*/
updatedAt: string;
};
};
responses: never;
@ -1046,7 +819,7 @@ export interface operations {
};
responses: {
/** @description Successful Response */
200: {
201: {
headers: {
[name: string]: unknown;
};
@ -1375,6 +1148,75 @@ export interface operations {
};
};
};
upload_asset_api_v1_documents__id__assets_post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_asset_api_v1_documents__id__assets_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_asset_api_v1_documents__id__assets__ref__get: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
ref: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_annotations_api_v1_documents__document_id__annotations_get: {
parameters: {
query?: never;
@ -1441,11 +1283,13 @@ export interface operations {
};
};
};
health_check_api_v1_health_get: {
list_versions_api_v1_documents__document_id__versions_get: {
parameters: {
query?: never;
header?: never;
path?: never;
path: {
document_id: string;
};
cookie?: never;
};
requestBody?: never;
@ -1456,23 +1300,161 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
"application/json": components["schemas"]["VersionListResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
verify_coords_api_v1_debug_verify_coords_post: {
create_version_api_v1_documents__document_id__versions_post: {
parameters: {
query?: never;
header?: never;
path?: never;
path: {
document_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["VerifyCoordsRequest"];
"application/json": components["schemas"]["VersionCreateRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionMeta"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_version_api_v1_documents__document_id__versions__version_id__get: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionDataResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_version_api_v1_documents__document_id__versions__version_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
restore_version_api_v1_documents__document_id__versions__version_id__restore_post: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
version_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VersionRestoreResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
export_document_api_v1_documents__document_id__export_post: {
parameters: {
query?: never;
header?: never;
path: {
document_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExportRequest"];
};
};
responses: {
@ -1496,4 +1478,26 @@ export interface operations {
};
};
};
health_check_api_v1_health_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
}

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"] },
"strokeColor": { "type": "string", "default": "#000000" },
"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"]
}