luna-v1-rescue #1
93 changed files with 6470 additions and 4039 deletions
20
.dockerignore
Normal file
20
.dockerignore
Normal 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
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Secret key for signing session cookies.
|
# Secret key for signing session cookies.
|
||||||
# Generate a strong random value: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Generate a strong random value: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
SECRET_KEY=change-me-in-production
|
SECRET_KEY=replace-with-a-long-random-secret
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Upload Limits
|
# Upload Limits
|
||||||
|
|
@ -40,9 +40,13 @@ COOKIE_SECURE=false
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Networking
|
# Networking
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Port the frontend container publishes. Your outer reverse proxy targets this.
|
# Port the PaperJet container publishes. Your outer reverse proxy targets this.
|
||||||
HTTP_PORT=4982
|
HTTP_PORT=4982
|
||||||
|
|
||||||
|
# Optional image reference for a pulled Forgejo build.
|
||||||
|
# Leave unset for local builds; set this before docker compose pull in production.
|
||||||
|
# PAPERJET_IMAGE=git.elijahkuntz.com/your-user/paperjet:latest
|
||||||
|
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
# Debug
|
# Debug
|
||||||
# --------------------------------------------
|
# --------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -69,3 +69,73 @@ jobs:
|
||||||
- name: Test (vitest)
|
- name: Test (vitest)
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npx vitest run
|
run: npx vitest run
|
||||||
|
|
||||||
|
container:
|
||||||
|
name: Container (Docker)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [backend, frontend]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate Compose configuration
|
||||||
|
run: docker compose --env-file .env.example config --quiet
|
||||||
|
|
||||||
|
- name: Build container image
|
||||||
|
run: |
|
||||||
|
IMAGE_PATH=$(printf 'git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')
|
||||||
|
docker build \
|
||||||
|
-t paperjet:ci \
|
||||||
|
-t "$IMAGE_PATH:latest" \
|
||||||
|
-t "$IMAGE_PATH:${{ github.sha }}" \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Run container smoke test
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
docker run \
|
||||||
|
--detach \
|
||||||
|
--name paperjet-ci \
|
||||||
|
--publish 18080:80 \
|
||||||
|
--env PAPERJET_SECRET_KEY=ci-only-secret \
|
||||||
|
--env PAPERJET_COOKIE_SECURE=false \
|
||||||
|
--env PAPERJET_DEBUG=false \
|
||||||
|
paperjet:ci
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
docker rm --force paperjet-ci >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
curl --fail --retry 30 --retry-delay 1 --retry-connrefused \
|
||||||
|
http://127.0.0.1:18080/
|
||||||
|
curl --fail --retry 30 --retry-delay 1 --retry-connrefused \
|
||||||
|
http://127.0.0.1:18080/api/v1/health
|
||||||
|
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
status=$(docker inspect --format '{{.State.Health.Status}}' paperjet-ci)
|
||||||
|
if [ "$status" = "healthy" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$status" = "unhealthy" ]; then
|
||||||
|
docker logs paperjet-ci
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
docker logs paperjet-ci
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Log into Local Registry
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Push main image tags
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
IMAGE_PATH=$(printf 'git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]')
|
||||||
|
docker push "$IMAGE_PATH:latest"
|
||||||
|
docker push "$IMAGE_PATH:${{ github.sha }}"
|
||||||
|
|
|
||||||
55
Dockerfile
Normal file
55
Dockerfile
Normal 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"]
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# Self-Hosted PDF Editor — Implementation Plan
|
# Self-Hosted PDF Editor — Implementation Plan
|
||||||
|
|
||||||
**Project codename:** `paperjet` (rename freely)
|
**Project codename:** `paperjet` (rename freely)
|
||||||
**Document version:** 1.1
|
**Document version:** 1.2
|
||||||
**Status:** Foundational specification — this document is the single source of truth. Every AI agent working on this project reads this file first, in full, before writing code.
|
**Status:** Foundational specification — this document is the single source of truth. Every AI agent working on this project reads this file first, in full, before writing code.
|
||||||
|
|
||||||
> **v1.1 changes:** resolved the four open questions — upload ceiling set to 200 MB; versioning reframed around accidental-loss recovery with a document-level **trash/restore** added as the primary safety net; cookie/proxy behavior pinned (direct HTTP for dev only, everything through the HTTPS proxy in production); signature capture specified as **draw + type-to-signature with live multi-font preview**.
|
> **v1.2 changes:** the runtime deployment is consolidated into one Docker image containing nginx, the compiled SPA, and FastAPI/uvicorn. The existing v1.1 decisions remain unchanged.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
A self-hosted, single-user, browser-based PDF editor with a Sejda-style annotation workflow. The user adds text boxes, freehand drawing, signatures, images, highlights, and shapes onto a PDF, moves and resizes them freely, and exports a flattened PDF. The application persists work continuously (autosave), keeps a per-file version history, and supports undo/redo.
|
A self-hosted, single-user, browser-based PDF editor with a Sejda-style annotation workflow. The user adds text boxes, freehand drawing, signatures, images, highlights, and shapes onto a PDF, moves and resizes them freely, and exports a flattened PDF. The application persists work continuously (autosave), keeps a per-file version history, and supports undo/redo.
|
||||||
|
|
||||||
It runs on the user's own server via Docker Compose, is reachable over LAN and over WAN through an nginx reverse proxy, and is protected by a single password. It depends on **no external programs, no external containers, and no third-party APIs**. Libraries and packages bundled into the application's own containers are permitted.
|
It runs on the user's own server via Docker Compose, is reachable over LAN and over WAN through an nginx reverse proxy, and is protected by a single password. It depends on **no external programs, no external containers, and no third-party APIs**. Libraries and packages bundled into the application image are permitted.
|
||||||
|
|
||||||
### Non-goals (v1)
|
### Non-goals (v1)
|
||||||
- Multi-user collaboration / real-time co-editing
|
- Multi-user collaboration / real-time co-editing
|
||||||
|
|
@ -78,8 +78,8 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
|
||||||
| ORM | SQLAlchemy 2.x | With Alembic for migrations |
|
| ORM | SQLAlchemy 2.x | With Alembic for migrations |
|
||||||
| Database | SQLite (WAL mode) | Single-user scale; embedded, no extra container |
|
| Database | SQLite (WAL mode) | Single-user scale; embedded, no extra container |
|
||||||
| Password hashing | Argon2id (`argon2-cffi`) | |
|
| Password hashing | Argon2id (`argon2-cffi`) | |
|
||||||
| Web server (frontend) | nginx | Serves the static React build; also the app-internal entry |
|
| Web server | nginx | Serves the static React build and proxies /api to loopback uvicorn |
|
||||||
| ASGI server (backend) | uvicorn | Behind nginx |
|
| ASGI server | uvicorn | Runs on loopback inside the same application image |
|
||||||
| Orchestration | Docker Compose | |
|
| Orchestration | Docker Compose | |
|
||||||
| External programs / containers / APIs | **None** | Hard requirement |
|
| External programs / containers / APIs | **None** | Hard requirement |
|
||||||
|
|
||||||
|
|
@ -98,13 +98,11 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
|
||||||
┌─────────────▼─────────────────────────────┐
|
┌─────────────▼─────────────────────────────┐
|
||||||
│ Docker Compose │
|
│ Docker Compose │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────┐ ┌────────────────┐ │
|
│ ┌──────────────────────────────────────┐ │
|
||||||
│ │ frontend │ │ backend │ │
|
│ │ paperjet image │ │
|
||||||
│ │ nginx │─────▶│ FastAPI │ │
|
│ │ nginx :80 ── /api ──▶ uvicorn :8000 │ │
|
||||||
│ │ serves SPA │ /api │ uvicorn │ │
|
│ │ React SPA FastAPI + PyMuPDF │ │
|
||||||
│ │ proxies /api│ │ PyMuPDF │ │
|
│ └───────────────────┬──────────────────┘ │
|
||||||
│ └──────────────┘ │ SQLAlchemy │ │
|
|
||||||
│ └───────┬────────┘ │
|
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌──────────▼────────┐ │
|
│ ┌──────────▼────────┐ │
|
||||||
│ │ volumes │ │
|
│ │ volumes │ │
|
||||||
|
|
@ -115,26 +113,29 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i
|
||||||
└────────────────────────────────────────────┘
|
└────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- The `frontend` nginx serves the SPA and reverse-proxies `/api/*` to the `backend`. This means the browser sees a single origin — no CORS complexity, cookies "just work."
|
- The single `paperjet` container serves the SPA and reverse-proxies `/api/*` to its loopback-only FastAPI process. The browser sees a single origin — no CORS complexity, cookies "just work."
|
||||||
- The user's outer reverse proxy points at the `frontend` container's published port.
|
- The user's outer reverse proxy points at the published port of the `paperjet` container.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Repository Structure
|
## 5. Repository Structure
|
||||||
|
|
||||||
A single monorepo. Two deployable images.
|
A single monorepo with one deployable runtime image. The Dockerfile uses a
|
||||||
|
Node build stage and a Python/nginx runtime stage.
|
||||||
|
|
||||||
```
|
```
|
||||||
paperjet/
|
paperjet/
|
||||||
├── README.md
|
├── README.md
|
||||||
├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first
|
├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-entrypoint.sh
|
||||||
|
├── .dockerignore
|
||||||
├── .env.example
|
├── .env.example
|
||||||
├── .github/workflows/ci.yml # lint + typecheck + test on push
|
├── .forgejo/workflows/ci.yml # lint, tests, image build, and publish
|
||||||
│
|
│
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── Dockerfile # multi-stage: build SPA, serve via nginx
|
│ ├── nginx.conf # SPA fallback + loopback /api proxy
|
||||||
│ ├── nginx.conf # SPA fallback + /api proxy
|
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ ├── tsconfig.json # strict: true
|
│ ├── tsconfig.json # strict: true
|
||||||
│ ├── vite.config.ts
|
│ ├── vite.config.ts
|
||||||
|
|
@ -163,7 +164,6 @@ paperjet/
|
||||||
│ └── types/ # generated from backend OpenAPI
|
│ └── types/ # generated from backend OpenAPI
|
||||||
│
|
│
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── Dockerfile
|
|
||||||
│ ├── pyproject.toml
|
│ ├── pyproject.toml
|
||||||
│ ├── alembic/ # migrations
|
│ ├── alembic/ # migrations
|
||||||
│ └── app/
|
│ └── app/
|
||||||
|
|
@ -342,7 +342,7 @@ In-document accidental edits during a single session are also covered by the in-
|
||||||
- The registry is a dict `{type: handler}`; adding a type adds a handler, nothing else.
|
- The registry is a dict `{type: handler}`; adding a type adds a handler, nothing else.
|
||||||
- Missing handler → log + skip, never crash (§7.3).
|
- Missing handler → log + skip, never crash (§7.3).
|
||||||
- Export accepts an optional `versionId` to export a historical version instead of the working state.
|
- Export accepts an optional `versionId` to export a historical version instead of the working state.
|
||||||
- Fonts: bundle inside the backend image (no external program) two font sets so server export matches the live editor exactly: (a) standard-metric fonts — the Liberation family for Helvetica/Times/Courier compatibility — for `text` annotations; and (b) a small curated set of **signature/script fonts** for typed signatures (§7.2, §12.5), the same faces offered in the editor's live preview. Embed used fonts into the output for portability.
|
- Fonts: bundle inside the application image (no external program) two font sets so server export matches the live editor exactly: (a) standard-metric fonts — the Liberation family for Helvetica/Times/Courier compatibility — for `text` annotations; and (b) a small curated set of **signature/script fonts** for typed signatures (§7.2, §12.5), the same faces offered in the editor's live preview. Embed used fonts into the output for portability.
|
||||||
|
|
||||||
### 9.4 Assets (binary annotation payloads)
|
### 9.4 Assets (binary annotation payloads)
|
||||||
- `POST /documents/{id}/assets` (multipart) stores a signature PNG or placed image on the `pdf_storage` volume, returns `{ ref }`.
|
- `POST /documents/{id}/assets` (multipart) stores a signature PNG or placed image on the `pdf_storage` volume, returns `{ ref }`.
|
||||||
|
|
@ -499,29 +499,29 @@ Target the Sejda feel: bright, airy, light mode, generous whitespace, a restrain
|
||||||
## 13. Docker Compose & Deployment
|
## 13. Docker Compose & Deployment
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml (illustrative; agents finalize)
|
# docker-compose.yml
|
||||||
services:
|
services:
|
||||||
backend:
|
paperjet:
|
||||||
build: ./backend
|
image: ${PAPERJET_IMAGE:-paperjet:local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
environment:
|
environment:
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
PAPERJET_SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting PaperJet}
|
||||||
- MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200}
|
PAPERJET_MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-200}
|
||||||
- TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30}
|
PAPERJET_TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
|
||||||
- AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30}
|
PAPERJET_AUTO_VERSION_RETENTION_DAYS: ${AUTO_VERSION_RETENTION_DAYS:-30}
|
||||||
- COOKIE_SECURE=${COOKIE_SECURE:-false} # false for direct-HTTP dev; set true in production (behind the HTTPS proxy)
|
PAPERJET_COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
|
PAPERJET_DATABASE_PATH: /data/db/app.sqlite
|
||||||
|
PAPERJET_PDF_STORAGE_PATH: /data/pdfs
|
||||||
|
PAPERJET_THUMBNAILS_PATH: /data/thumbnails
|
||||||
|
PAPERJET_DEBUG: ${DEBUG:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- pdf_storage:/data/pdfs
|
- pdf_storage:/data/pdfs
|
||||||
- thumbnails:/data/thumbnails
|
- thumbnails:/data/thumbnails
|
||||||
- db:/data/db
|
- db:/data/db
|
||||||
expose:
|
|
||||||
- "8000"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build: ./frontend
|
|
||||||
depends_on: [backend]
|
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-8080}:80" # user's outer reverse proxy targets this
|
- "${HTTP_PORT:-4982}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -530,10 +530,10 @@ volumes:
|
||||||
db:
|
db:
|
||||||
```
|
```
|
||||||
|
|
||||||
- `frontend/nginx.conf` serves the SPA with history-API fallback and proxies `/api/` to `backend:8000`, passing through cookies and the `X-Requested-With` header. Set `client_max_body_size` to match `MAX_UPLOAD_MB` (≥200 MB) so large PDFs aren't rejected at the proxy layer — and remind the user to raise the same limit in their **outer** reverse proxy, since that's the other place a large upload can be silently truncated.
|
- `frontend/nginx.conf` serves the SPA with history-API fallback and proxies `/api/` to `127.0.0.1:8000`, passing through cookies and the `X-Requested-With` header. Set `client_max_body_size` to match `MAX_UPLOAD_MB` (≥200 MB) so large PDFs aren't rejected at the proxy layer — and remind the user to raise the same limit in their **outer** reverse proxy, since that's the other place a large upload can be silently truncated.
|
||||||
- A 200 MB ceiling comfortably covers 100–200 page documents with embedded images; PDF.js page virtualization (§12.2) keeps the editor responsive on documents that large.
|
- A 200 MB ceiling comfortably covers 100–200 page documents with embedded images; PDF.js page virtualization (§12.2) keeps the editor responsive on documents that large.
|
||||||
- Backend Dockerfile installs Python deps and the bundled fonts; PyMuPDF comes from its wheel (no system packages, no external programs).
|
- The root Dockerfile builds the SPA with Node, installs production Python dependencies and bundled fonts, and copies both into one nginx/Python runtime image.
|
||||||
- Frontend Dockerfile is multi-stage: Node builds the SPA, the result is copied into an nginx image.
|
- docker-entrypoint.sh supervises nginx and loopback uvicorn so a failed child process stops the container cleanly.
|
||||||
- All persistent state lives in named volumes mountable on the user's Unraid array. Document the volume → host-path mapping for backups.
|
- All persistent state lives in named volumes mountable on the user's Unraid array. Document the volume → host-path mapping for backups.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -563,7 +563,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the
|
||||||
3. **One coordinate-transform module.** All space conversions go through `lib/coords.ts`. Never compute transforms inline in a component or duplicate the logic. Canonical space is always PDF points, top-left origin (§8).
|
3. **One coordinate-transform module.** All space conversions go through `lib/coords.ts`. Never compute transforms inline in a component or duplicate the logic. Canonical space is always PDF points, top-left origin (§8).
|
||||||
4. **Store coordinates only in canonical space.** Never persist screen pixels.
|
4. **Store coordinates only in canonical space.** Never persist screen pixels.
|
||||||
5. **Types are generated, not hand-written.** After any backend schema change, regenerate the OpenAPI types for the frontend. Keep the TS annotation union and the Pydantic models in sync with `shared/annotation-schema.json`.
|
5. **Types are generated, not hand-written.** After any backend schema change, regenerate the OpenAPI types for the frontend. Keep the TS annotation union and the Pydantic models in sync with `shared/annotation-schema.json`.
|
||||||
6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the existing two images are fine. If a task seems to need an external dependency, stop and flag it; do not add one.
|
6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the application image are fine. If a task seems to need an external dependency, stop and flag it; do not add one.
|
||||||
7. **Extend via registries.** New annotation types/tools register themselves; never scatter `switch(type)` logic across the codebase. Unknown types must round-trip without crashing.
|
7. **Extend via registries.** New annotation types/tools register themselves; never scatter `switch(type)` logic across the codebase. Unknown types must round-trip without crashing.
|
||||||
8. **Security code is review-gated.** Do not modify auth, session, hashing, upload validation, or path handling without explicitly calling it out for review. Never log secrets or password material.
|
8. **Security code is review-gated.** Do not modify auth, session, hashing, upload validation, or path handling without explicitly calling it out for review. Never log secrets or password material.
|
||||||
9. **Tests are required for the risk areas:** the coordinate module (round-trip + cross-engine), the export renderer per handler, auth flows, and upload validation. A feature touching these isn't done until its tests pass.
|
9. **Tests are required for the risk areas:** the coordinate module (round-trip + cross-engine), the export renderer per handler, auth flows, and upload validation. A feature touching these isn't done until its tests pass.
|
||||||
|
|
@ -580,7 +580,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the
|
||||||
Sequenced so each phase is independently testable and the riskiest correctness work (coordinates, export) is validated early.
|
Sequenced so each phase is independently testable and the riskiest correctness work (coordinates, export) is validated early.
|
||||||
|
|
||||||
**Phase 0 — Scaffolding**
|
**Phase 0 — Scaffolding**
|
||||||
Monorepo, both Dockerfiles, compose, `.env.example`, CI (lint/typecheck/test), nginx config, empty FastAPI app with `/health`, SPA shell with routing. *Done when:* `docker compose up` serves a blank authenticated-shell app and `/api/v1/health` responds.
|
Monorepo, the root Dockerfile and entrypoint, single-service Compose, `.env.example`, CI (lint/typecheck/test/container smoke), nginx config, empty FastAPI app with `/health`, SPA shell with routing. *Done when:* `docker compose up` serves a blank authenticated-shell app and `/api/v1/health` responds.
|
||||||
|
|
||||||
**Phase 1 — Auth & Library core**
|
**Phase 1 — Auth & Library core**
|
||||||
First-run setup, login/logout, session cookie, rate limiting. Upload (button + drag-drop), document list, thumbnails, soft delete + **trash/restore** (single + bulk, empty trash, retention purge sweep), home page (recent + library + trash). *Done when:* a user can log in, upload, see, delete, and restore PDFs.
|
First-run setup, login/logout, session cookie, rate limiting. Upload (button + drag-drop), document list, thumbnails, soft delete + **trash/restore** (single + bulk, empty trash, retention purge sweep), home page (recent + library + trash). *Done when:* a user can log in, upload, see, delete, and restore PDFs.
|
||||||
|
|
@ -616,4 +616,4 @@ All four open questions are now settled:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*End of plan v1.1. Amendments are made to this document first, then to code.*
|
*End of plan v1.2. Amendments are made to this document first, then to code.*
|
||||||
|
|
|
||||||
86
README.md
Normal file
86
README.md
Normal 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.
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
from app.models import * # noqa: F401, F403 — ensure all models are imported
|
from app.models import * # noqa: F403 — ensure all models are imported
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,14 @@
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1.annotations import router as annotations_router
|
||||||
|
from app.api.v1.assets import router as assets_router
|
||||||
from app.api.v1.auth import router as auth_router
|
from app.api.v1.auth import router as auth_router
|
||||||
from app.api.v1.documents import router as documents_router
|
from app.api.v1.documents import router as documents_router
|
||||||
|
from app.api.v1.export import router as export_router
|
||||||
from app.api.v1.health import router as health_router
|
from app.api.v1.health import router as health_router
|
||||||
|
from app.api.v1.versions import router as versions_router
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
|
|
@ -15,17 +20,18 @@ router.include_router(auth_router)
|
||||||
router.include_router(documents_router)
|
router.include_router(documents_router)
|
||||||
|
|
||||||
# Assets
|
# Assets
|
||||||
from app.api.v1.assets import router as assets_router
|
|
||||||
router.include_router(assets_router)
|
router.include_router(assets_router)
|
||||||
|
|
||||||
# Annotations
|
# Annotations
|
||||||
from app.api.v1.annotations import router as annotations_router
|
|
||||||
router.include_router(annotations_router)
|
router.include_router(annotations_router)
|
||||||
|
|
||||||
|
# Versions and export
|
||||||
|
router.include_router(versions_router)
|
||||||
|
router.include_router(export_router)
|
||||||
|
|
||||||
# Health (unauthenticated)
|
# Health (unauthenticated)
|
||||||
router.include_router(health_router)
|
router.include_router(health_router)
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
from app.api.v1.debug import router as debug_router
|
from app.api.v1.debug import router as debug_router
|
||||||
router.include_router(debug_router)
|
router.include_router(debug_router)
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,95 @@
|
||||||
from typing import Any
|
"""Opaque working-state annotation endpoints."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, timezone
|
|
||||||
import json
|
|
||||||
|
|
||||||
from app.db import get_db
|
|
||||||
from app.models.document import Document
|
|
||||||
from app.models.annotation_state import AnnotationState
|
|
||||||
from app.auth.dependencies import get_current_user, verify_csrf
|
from app.auth.dependencies import get_current_user, verify_csrf
|
||||||
from app.schemas.annotations import AnnotationStateResponse, AnnotationStateUpdateRequest, AnnotationStateUpdateResponse
|
from app.db import get_db
|
||||||
|
from app.models.annotation_state import AnnotationState
|
||||||
|
from app.models.document import Document
|
||||||
|
from app.schemas.annotations import (
|
||||||
|
AnnotationPayload,
|
||||||
|
AnnotationStateResponse,
|
||||||
|
AnnotationStateUpdateRequest,
|
||||||
|
AnnotationStateUpdateResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/documents", tags=["annotations"])
|
||||||
|
|
||||||
router = APIRouter(prefix="/documents", tags=["Annotations"])
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
@router.get("/{document_id}/annotations", response_model=AnnotationStateResponse)
|
|
||||||
async def get_annotations(
|
def _utc(value: datetime) -> datetime:
|
||||||
document_id: str,
|
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user_id: Any = Depends(get_current_user)
|
|
||||||
):
|
def _document_or_404(document_id: str, db: Session) -> Document:
|
||||||
doc = db.scalar(select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)))
|
document = db.scalar(
|
||||||
if not doc:
|
select(Document).where(Document.id == document_id, Document.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
if not document:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
return document
|
||||||
|
|
||||||
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
|
|
||||||
|
|
||||||
|
def _read_data(state: AnnotationState | None) -> list[AnnotationPayload]:
|
||||||
if not state:
|
if not state:
|
||||||
return AnnotationStateResponse(data=[], updatedAt=datetime.now(timezone.utc))
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(state.data)
|
data = json.loads(state.data)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
data = []
|
return []
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
return AnnotationStateResponse(
|
|
||||||
data=data,
|
@router.get("/{document_id}/annotations", response_model=AnnotationStateResponse)
|
||||||
updatedAt=datetime.fromisoformat(state.updated_at)
|
def get_annotations(
|
||||||
|
document_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user_id: int = Depends(get_current_user),
|
||||||
|
) -> AnnotationStateResponse:
|
||||||
|
_document_or_404(document_id, db)
|
||||||
|
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
|
||||||
|
updated_at = datetime.now(UTC) if not state else _utc(datetime.fromisoformat(state.updated_at))
|
||||||
|
return AnnotationStateResponse(data=_read_data(state), updatedAt=updated_at)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{document_id}/annotations",
|
||||||
|
response_model=AnnotationStateUpdateResponse,
|
||||||
|
dependencies=[Depends(verify_csrf)],
|
||||||
)
|
)
|
||||||
|
def update_annotations(
|
||||||
@router.put("/{document_id}/annotations", response_model=AnnotationStateUpdateResponse, dependencies=[Depends(verify_csrf)])
|
|
||||||
async def update_annotations(
|
|
||||||
document_id: str,
|
document_id: str,
|
||||||
request: AnnotationStateUpdateRequest,
|
request: AnnotationStateUpdateRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user_id: Any = Depends(get_current_user)
|
_user_id: int = Depends(get_current_user),
|
||||||
):
|
) -> AnnotationStateUpdateResponse:
|
||||||
doc = db.scalar(select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)))
|
document = _document_or_404(document_id, db)
|
||||||
if not doc:
|
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
|
||||||
|
|
||||||
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
|
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
|
||||||
|
|
||||||
# Check for stale tab
|
|
||||||
if state and request.baseUpdatedAt:
|
if state and request.baseUpdatedAt:
|
||||||
current_updated_at = datetime.fromisoformat(state.updated_at).replace(tzinfo=timezone.utc)
|
current_updated_at = _utc(datetime.fromisoformat(state.updated_at))
|
||||||
request_updated_at = request.baseUpdatedAt.replace(tzinfo=timezone.utc)
|
request_updated_at = _utc(request.baseUpdatedAt)
|
||||||
# Allow a small grace period for timezone/parsing discrepancies
|
|
||||||
if abs((current_updated_at - request_updated_at).total_seconds()) > 1.0:
|
if abs((current_updated_at - request_updated_at).total_seconds()) > 1.0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail=f"Conflict: annotations have been modified since {request.baseUpdatedAt}"
|
detail=f"Conflict: annotations have been modified since {request.baseUpdatedAt}",
|
||||||
)
|
)
|
||||||
|
|
||||||
now = _now_iso()
|
now = _now_iso()
|
||||||
|
data_json = json.dumps(request.data, separators=(",", ":"))
|
||||||
import json
|
if state:
|
||||||
|
|
||||||
# serialize request.data list of models to json string
|
|
||||||
data_json = json.dumps([a.model_dump(mode="json", by_alias=True) for a in request.data])
|
|
||||||
|
|
||||||
if not state:
|
|
||||||
state = AnnotationState(
|
|
||||||
document_id=document_id,
|
|
||||||
data=data_json,
|
|
||||||
updated_at=now
|
|
||||||
)
|
|
||||||
db.add(state)
|
|
||||||
else:
|
|
||||||
state.data = data_json
|
state.data = data_json
|
||||||
state.updated_at = now
|
state.updated_at = now
|
||||||
|
else:
|
||||||
# Bump document updated_at
|
db.add(AnnotationState(document_id=document_id, data=data_json, updated_at=now))
|
||||||
doc.updated_at = now
|
document.updated_at = now
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return AnnotationStateUpdateResponse(updatedAt=datetime.fromisoformat(now))
|
return AnnotationStateUpdateResponse(updatedAt=datetime.fromisoformat(now))
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,99 @@
|
||||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
"""Authenticated binary assets used by annotations."""
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
import uuid
|
import uuid
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.db import get_db
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
from app.auth.dependencies import get_current_user
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user, verify_csrf
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models.document import Document
|
||||||
|
|
||||||
router = APIRouter(prefix="/documents", tags=["Assets"])
|
router = APIRouter(prefix="/documents", tags=["Assets"])
|
||||||
|
_ASSET_CHUNK_SIZE = 1024 * 1024
|
||||||
|
_MAX_ASSET_BYTES = 25 * 1024 * 1024
|
||||||
|
|
||||||
def get_asset_path(document_id: str, ref: str) -> Path:
|
|
||||||
# Ensure directory exists
|
|
||||||
dir_path = settings.PDF_STORAGE_PATH / "assets" / document_id
|
|
||||||
dir_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
return dir_path / ref
|
|
||||||
|
|
||||||
@router.post("/{id}/assets")
|
def get_asset_path(document_id: str, ref: str, *, create: bool = False) -> Path:
|
||||||
|
"""Return a safe asset path; only writes are allowed to create directories."""
|
||||||
|
try:
|
||||||
|
asset_ref = str(uuid.UUID(ref))
|
||||||
|
except ValueError as err:
|
||||||
|
raise HTTPException(status_code=404, detail="Asset not found") from err
|
||||||
|
|
||||||
|
directory = settings.PDF_STORAGE_PATH / "assets" / document_id
|
||||||
|
if create:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
return directory / asset_ref
|
||||||
|
|
||||||
|
|
||||||
|
def _is_supported_image(filepath: Path) -> bool:
|
||||||
|
with filepath.open("rb") as file:
|
||||||
|
header = file.read(12)
|
||||||
|
return (
|
||||||
|
header.startswith(b"\x89PNG\r\n\x1a\n")
|
||||||
|
or header.startswith(b"\xff\xd8\xff")
|
||||||
|
or header.startswith((b"GIF87a", b"GIF89a"))
|
||||||
|
or (header.startswith(b"RIFF") and header[8:12] == b"WEBP")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{id}/assets", dependencies=[Depends(verify_csrf)])
|
||||||
async def upload_asset(
|
async def upload_asset(
|
||||||
id: str,
|
id: str,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
user_id: int = Depends(get_current_user),
|
user_id: int = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
):
|
) -> dict[str, str]:
|
||||||
"""Upload a binary asset (like an image or signature) for a document."""
|
"""Upload a PNG, JPEG, GIF, or WebP annotation asset."""
|
||||||
# In a real app we'd verify the user owns the document here
|
document = db.scalar(select(Document).where(Document.id == id))
|
||||||
# For this single-user app, we trust the ID
|
if not document:
|
||||||
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
if not file.content_type or not file.content_type.startswith("image/"):
|
|
||||||
raise HTTPException(status_code=400, detail="Only image assets are supported")
|
|
||||||
|
|
||||||
ref = str(uuid.uuid4())
|
ref = str(uuid.uuid4())
|
||||||
filepath = get_asset_path(id, ref)
|
filepath = get_asset_path(id, ref, create=True)
|
||||||
|
total_bytes = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(filepath, "wb") as f:
|
with filepath.open("wb") as destination:
|
||||||
shutil.copyfileobj(file.file, f)
|
while chunk := file.file.read(_ASSET_CHUNK_SIZE):
|
||||||
except Exception as e:
|
total_bytes += len(chunk)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to save asset: {e}")
|
if total_bytes > _MAX_ASSET_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Image asset is too large")
|
||||||
|
destination.write(chunk)
|
||||||
|
except HTTPException:
|
||||||
|
filepath.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
except Exception as err:
|
||||||
|
filepath.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to save asset") from err
|
||||||
|
|
||||||
|
if not _is_supported_image(filepath):
|
||||||
|
filepath.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Only PNG, JPEG, GIF, and WebP images are supported",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"ref": ref, "url": f"/api/v1/documents/{id}/assets/{ref}"}
|
||||||
|
|
||||||
return {
|
|
||||||
"ref": ref,
|
|
||||||
"url": f"/api/v1/documents/{id}/assets/{ref}"
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.get("/{id}/assets/{ref}")
|
@router.get("/{id}/assets/{ref}")
|
||||||
async def get_asset(
|
async def get_asset(
|
||||||
id: str,
|
id: str,
|
||||||
ref: str,
|
ref: str,
|
||||||
):
|
db: Session = Depends(get_db),
|
||||||
"""Serve a binary asset."""
|
user_id: int = Depends(get_current_user),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""Serve an annotation asset to an authenticated session."""
|
||||||
|
document = db.scalar(select(Document).where(Document.id == id))
|
||||||
|
if not document:
|
||||||
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|
||||||
filepath = get_asset_path(id, ref)
|
filepath = get_asset_path(id, ref)
|
||||||
if not filepath.exists() or not filepath.is_file():
|
if not filepath.exists() or not filepath.is_file():
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
raise HTTPException(status_code=404, detail="Asset not found")
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ async def verify_coords(req: VerifyCoordsRequest):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = pymupdf.open(filepath)
|
doc = pymupdf.open(filepath)
|
||||||
except Exception as e:
|
except Exception as error:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(error)) from error
|
||||||
|
|
||||||
if req.page < 0 or req.page >= len(doc):
|
if req.page < 0 or req.page >= len(doc):
|
||||||
doc.close()
|
doc.close()
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,31 @@
|
||||||
"""Document management endpoints."""
|
"""Document management endpoints."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy import desc
|
from sqlalchemy import desc
|
||||||
from datetime import datetime, timezone
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth.dependencies import get_current_user, verify_csrf
|
from app.auth.dependencies import get_current_user, verify_csrf
|
||||||
|
from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models.document import Document
|
|
||||||
from app.models.annotation_state import AnnotationState
|
from app.models.annotation_state import AnnotationState
|
||||||
|
from app.models.document import Document
|
||||||
from app.models.version import Version
|
from app.models.version import Version
|
||||||
from app.schemas.document import (
|
from app.schemas.document import (
|
||||||
DocumentListResponse, DocumentMeta, BulkDeleteRequest,
|
BulkDeleteRequest,
|
||||||
BulkRestoreRequest, DocumentUpdateRequest
|
BulkRestoreRequest,
|
||||||
|
DocumentListResponse,
|
||||||
|
DocumentMeta,
|
||||||
|
DocumentUpdateRequest,
|
||||||
)
|
)
|
||||||
from app.services.storage import save_upload_file, delete_pdf_file
|
from app.services.assets import delete_asset_directory
|
||||||
from app.services.thumbnails import generate_thumbnail, delete_thumbnail
|
from app.services.storage import delete_pdf_file, safe_filename, save_upload_file
|
||||||
from app.config import settings
|
from app.services.thumbnails import delete_thumbnail, generate_thumbnail
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/documents",
|
prefix="/documents", tags=["documents"], dependencies=[Depends(get_current_user)]
|
||||||
tags=["documents"],
|
|
||||||
dependencies=[Depends(get_current_user)]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -31,8 +34,8 @@ def list_documents(
|
||||||
query: str = "",
|
query: str = "",
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
):
|
) -> DocumentListResponse:
|
||||||
"""List non-trashed documents."""
|
"""List non-trashed documents."""
|
||||||
q = db.query(Document).filter(Document.deleted_at.is_(None))
|
q = db.query(Document).filter(Document.deleted_at.is_(None))
|
||||||
if query:
|
if query:
|
||||||
|
|
@ -41,27 +44,35 @@ def list_documents(
|
||||||
total = q.count()
|
total = q.count()
|
||||||
items = q.order_by(desc(Document.updated_at)).offset(skip).limit(limit).all()
|
items = q.order_by(desc(Document.updated_at)).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
return DocumentListResponse(items=items, total=total)
|
return DocumentListResponse(
|
||||||
|
items=[DocumentMeta.model_validate(item) for item in items], total=total
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/trash", response_model=DocumentListResponse)
|
@router.get("/trash", response_model=DocumentListResponse)
|
||||||
def list_trash(
|
def list_trash(
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
):
|
) -> DocumentListResponse:
|
||||||
"""List trashed documents."""
|
"""List trashed documents."""
|
||||||
q = db.query(Document).filter(Document.deleted_at.is_not(None))
|
q = db.query(Document).filter(Document.deleted_at.is_not(None))
|
||||||
total = q.count()
|
total = q.count()
|
||||||
items = q.order_by(desc(Document.deleted_at)).offset(skip).limit(limit).all()
|
items = q.order_by(desc(Document.deleted_at)).offset(skip).limit(limit).all()
|
||||||
return DocumentListResponse(items=items, total=total)
|
return DocumentListResponse(
|
||||||
|
items=[DocumentMeta.model_validate(item) for item in items], total=total
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
|
@router.post(
|
||||||
|
"",
|
||||||
|
dependencies=[Depends(verify_csrf)],
|
||||||
|
response_model=DocumentMeta,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
async def upload_document(
|
async def upload_document(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...), db: Session = Depends(get_db)
|
||||||
db: Session = Depends(get_db)
|
) -> DocumentMeta:
|
||||||
):
|
|
||||||
"""Upload a new PDF document."""
|
"""Upload a new PDF document."""
|
||||||
# Note: Starlette/FastAPI loads file into memory/spooled file.
|
# Note: Starlette/FastAPI loads file into memory/spooled file.
|
||||||
# To truly enforce max size before reading we rely on Nginx and logic in storage.py.
|
# To truly enforce max size before reading we rely on Nginx and logic in storage.py.
|
||||||
|
|
@ -72,56 +83,73 @@ async def upload_document(
|
||||||
file_id, size_bytes, page_count = await save_upload_file(file)
|
file_id, size_bytes, page_count = await save_upload_file(file)
|
||||||
|
|
||||||
# Generate thumbnail synchronously
|
# Generate thumbnail synchronously
|
||||||
generate_thumbnail(file_id)
|
thumbnail_generated = generate_thumbnail(file_id)
|
||||||
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
|
filename = safe_filename(file.filename)
|
||||||
doc = Document(
|
doc = Document(
|
||||||
id=file_id,
|
id=file_id,
|
||||||
title=file.filename or "Untitled",
|
title=filename,
|
||||||
original_filename=file.filename or "Untitled.pdf",
|
original_filename=filename,
|
||||||
file_path=str(settings.PDF_STORAGE_PATH / f"{file_id}.pdf"),
|
file_path=str(settings.PDF_STORAGE_PATH / f"{file_id}.pdf"),
|
||||||
|
thumbnail_path=(
|
||||||
|
str(settings.THUMBNAILS_PATH / f"{file_id}.png") if thumbnail_generated else None
|
||||||
|
),
|
||||||
page_count=page_count,
|
page_count=page_count,
|
||||||
size_bytes=size_bytes,
|
size_bytes=size_bytes,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now
|
updated_at=now,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
db.add(doc)
|
db.add(doc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
delete_pdf_file(file_id)
|
||||||
|
delete_thumbnail(file_id)
|
||||||
|
delete_asset_directory(file_id)
|
||||||
|
raise
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
return doc
|
return DocumentMeta.model_validate(doc)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}", response_model=DocumentMeta)
|
@router.get("/{id}", response_model=DocumentMeta)
|
||||||
def get_document(id: str, db: Session = Depends(get_db)):
|
def get_document(id: str, db: Session = Depends(get_db)) -> DocumentMeta:
|
||||||
doc = db.query(Document).filter(Document.id == id).first()
|
doc = db.query(Document).filter(Document.id == id, Document.deleted_at.is_(None)).first()
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
return doc
|
return DocumentMeta.model_validate(doc)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{id}", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
|
@router.patch("/{id}", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
|
||||||
def update_document(id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db)):
|
def update_document(
|
||||||
|
id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db)
|
||||||
|
) -> DocumentMeta:
|
||||||
doc = db.query(Document).filter(Document.id == id).first()
|
doc = db.query(Document).filter(Document.id == id).first()
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|
||||||
if update.title is not None:
|
if update.title is not None:
|
||||||
doc.title = update.title
|
title = update.title.strip()
|
||||||
|
if not title:
|
||||||
|
raise HTTPException(status_code=422, detail="Title cannot be empty")
|
||||||
|
doc.title = title
|
||||||
|
|
||||||
if update.in_trash is not None:
|
if update.in_trash is not None:
|
||||||
if update.in_trash:
|
if update.in_trash:
|
||||||
doc.deleted_at = datetime.now(timezone.utc).isoformat()
|
doc.deleted_at = datetime.now(UTC).isoformat()
|
||||||
else:
|
else:
|
||||||
doc.deleted_at = None
|
doc.deleted_at = None
|
||||||
|
|
||||||
doc.updated_at = datetime.now(timezone.utc).isoformat()
|
doc.updated_at = datetime.now(UTC).isoformat()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
return doc
|
return DocumentMeta.model_validate(doc)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{id}", dependencies=[Depends(verify_csrf)])
|
@router.delete("/{id}", dependencies=[Depends(verify_csrf)])
|
||||||
def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_db)):
|
def delete_document(
|
||||||
|
id: str, permanent: bool = False, db: Session = Depends(get_db)
|
||||||
|
) -> dict[str, str]:
|
||||||
"""Soft delete by default. Hard delete if permanent=True AND already in trash."""
|
"""Soft delete by default. Hard delete if permanent=True AND already in trash."""
|
||||||
doc = db.query(Document).filter(Document.id == id).first()
|
doc = db.query(Document).filter(Document.id == id).first()
|
||||||
if not doc:
|
if not doc:
|
||||||
|
|
@ -134,6 +162,7 @@ def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_
|
||||||
# Hard delete
|
# Hard delete
|
||||||
delete_pdf_file(doc.id)
|
delete_pdf_file(doc.id)
|
||||||
delete_thumbnail(doc.id)
|
delete_thumbnail(doc.id)
|
||||||
|
delete_asset_directory(doc.id)
|
||||||
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
|
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
|
||||||
db.query(Version).filter(Version.document_id == doc.id).delete()
|
db.query(Version).filter(Version.document_id == doc.id).delete()
|
||||||
db.delete(doc)
|
db.delete(doc)
|
||||||
|
|
@ -141,52 +170,57 @@ def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
else:
|
else:
|
||||||
# Soft delete
|
# Soft delete
|
||||||
doc.deleted_at = datetime.now(timezone.utc).isoformat()
|
doc.deleted_at = datetime.now(UTC).isoformat()
|
||||||
|
doc.updated_at = doc.deleted_at
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "trashed"}
|
return {"status": "trashed"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/restore", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
|
@router.post("/{id}/restore", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta)
|
||||||
def restore_document(id: str, db: Session = Depends(get_db)):
|
def restore_document(id: str, db: Session = Depends(get_db)) -> DocumentMeta:
|
||||||
doc = db.query(Document).filter(Document.id == id).first()
|
doc = db.query(Document).filter(Document.id == id).first()
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|
||||||
doc.deleted_at = None
|
doc.deleted_at = None
|
||||||
doc.updated_at = datetime.now(timezone.utc).isoformat()
|
doc.updated_at = datetime.now(UTC).isoformat()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
return doc
|
return DocumentMeta.model_validate(doc)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bulk-delete", dependencies=[Depends(verify_csrf)])
|
@router.post("/bulk-delete", dependencies=[Depends(verify_csrf)])
|
||||||
def bulk_delete(data: BulkDeleteRequest, db: Session = Depends(get_db)):
|
def bulk_delete(data: BulkDeleteRequest, db: Session = Depends(get_db)) -> dict[str, int | str]:
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
db.query(Document).filter(Document.id.in_(data.ids)).update({
|
count = (
|
||||||
Document.deleted_at: now
|
db.query(Document)
|
||||||
}, synchronize_session=False)
|
.filter(Document.id.in_(data.ids), Document.deleted_at.is_(None))
|
||||||
|
.update({Document.deleted_at: now}, synchronize_session=False)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "ok", "count": len(data.ids)}
|
return {"status": "ok", "count": count}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bulk-restore", dependencies=[Depends(verify_csrf)])
|
@router.post("/bulk-restore", dependencies=[Depends(verify_csrf)])
|
||||||
def bulk_restore(data: BulkRestoreRequest, db: Session = Depends(get_db)):
|
def bulk_restore(data: BulkRestoreRequest, db: Session = Depends(get_db)) -> dict[str, int | str]:
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
db.query(Document).filter(Document.id.in_(data.ids)).update({
|
count = (
|
||||||
Document.deleted_at: None,
|
db.query(Document)
|
||||||
Document.updated_at: now
|
.filter(Document.id.in_(data.ids), Document.deleted_at.is_not(None))
|
||||||
}, synchronize_session=False)
|
.update({Document.deleted_at: None, Document.updated_at: now}, synchronize_session=False)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "ok", "count": len(data.ids)}
|
return {"status": "ok", "count": count}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trash/empty", dependencies=[Depends(verify_csrf)])
|
@router.post("/trash/empty", dependencies=[Depends(verify_csrf)])
|
||||||
def empty_trash(db: Session = Depends(get_db)):
|
def empty_trash(db: Session = Depends(get_db)) -> dict[str, int | str]:
|
||||||
docs = db.query(Document).filter(Document.deleted_at.is_not(None)).all()
|
docs = db.query(Document).filter(Document.deleted_at.is_not(None)).all()
|
||||||
count = 0
|
count = 0
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
delete_pdf_file(doc.id)
|
delete_pdf_file(doc.id)
|
||||||
delete_thumbnail(doc.id)
|
delete_thumbnail(doc.id)
|
||||||
|
delete_asset_directory(doc.id)
|
||||||
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
|
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
|
||||||
db.query(Version).filter(Version.document_id == doc.id).delete()
|
db.query(Version).filter(Version.document_id == doc.id).delete()
|
||||||
db.delete(doc)
|
db.delete(doc)
|
||||||
|
|
@ -196,7 +230,7 @@ def empty_trash(db: Session = Depends(get_db)):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}/file")
|
@router.get("/{id}/file")
|
||||||
def get_document_file(id: str, db: Session = Depends(get_db)):
|
def get_document_file(id: str, db: Session = Depends(get_db)) -> FileResponse:
|
||||||
doc = db.query(Document).filter(Document.id == id).first()
|
doc = db.query(Document).filter(Document.id == id).first()
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|
@ -209,7 +243,7 @@ def get_document_file(id: str, db: Session = Depends(get_db)):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}/thumbnail")
|
@router.get("/{id}/thumbnail")
|
||||||
def get_document_thumbnail(id: str, db: Session = Depends(get_db)):
|
def get_document_thumbnail(id: str, db: Session = Depends(get_db)) -> FileResponse:
|
||||||
doc = db.query(Document).filter(Document.id == id).first()
|
doc = db.query(Document).filter(Document.id == id).first()
|
||||||
if not doc:
|
if not doc:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|
|
||||||
94
backend/app/api/v1/export.py
Normal file
94
backend/app/api/v1/export.py
Normal 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}"'},
|
||||||
|
)
|
||||||
203
backend/app/api/v1/versions.py
Normal file
203
backend/app/api/v1/versions.py
Normal 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)
|
||||||
BIN
backend/app/assets/fonts/GreatVibes-Regular.ttf
Normal file
BIN
backend/app/assets/fonts/GreatVibes-Regular.ttf
Normal file
Binary file not shown.
93
backend/app/assets/fonts/OFL.txt
Normal file
93
backend/app/assets/fonts/OFL.txt
Normal 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.
|
||||||
BIN
backend/app/assets/fonts/allura.ttf
Normal file
BIN
backend/app/assets/fonts/allura.ttf
Normal file
Binary file not shown.
BIN
backend/app/assets/fonts/caveat.ttf
Normal file
BIN
backend/app/assets/fonts/caveat.ttf
Normal file
Binary file not shown.
BIN
backend/app/assets/fonts/dancing-script.ttf
Normal file
BIN
backend/app/assets/fonts/dancing-script.ttf
Normal file
Binary file not shown.
BIN
backend/app/assets/fonts/sacramento.ttf
Normal file
BIN
backend/app/assets/fonts/sacramento.ttf
Normal file
Binary file not shown.
|
|
@ -1,10 +1,11 @@
|
||||||
"""FastAPI dependencies for authentication and security."""
|
"""FastAPI dependencies for authentication and security."""
|
||||||
|
|
||||||
from fastapi import Request, HTTPException
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
from app.auth.session import verify_session
|
from app.auth.session import verify_session
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(request: Request) -> int:
|
def get_current_user(request: Request) -> int:
|
||||||
"""
|
"""
|
||||||
Dependency that extracts and validates the session cookie.
|
Dependency that extracts and validates the session cookie.
|
||||||
|
|
@ -27,9 +28,11 @@ def verify_csrf(request: Request) -> None:
|
||||||
Dependency to mitigate CSRF for cookie-based auth.
|
Dependency to mitigate CSRF for cookie-based auth.
|
||||||
Requires X-Requested-With header on all mutating requests.
|
Requires X-Requested-With header on all mutating requests.
|
||||||
"""
|
"""
|
||||||
if request.method in ["POST", "PUT", "PATCH", "DELETE"]:
|
if (
|
||||||
if request.headers.get("X-Requested-With") != "XMLHttpRequest":
|
request.method in ["POST", "PUT", "PATCH", "DELETE"]
|
||||||
|
and request.headers.get("X-Requested-With") != "XMLHttpRequest"
|
||||||
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="CSRF check failed: missing X-Requested-With header"
|
detail="CSRF check failed: missing X-Requested-With header",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
"""Argon2id password hashing and verification."""
|
"""Argon2id password hashing and verification."""
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||||
|
|
||||||
# Argon2id is the default for PasswordHasher
|
# Argon2id is the default for PasswordHasher
|
||||||
ph = PasswordHasher()
|
ph = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""Hash a plaintext password."""
|
"""Hash a plaintext password."""
|
||||||
return ph.hash(password)
|
return ph.hash(password)
|
||||||
|
|
||||||
|
|
||||||
def verify_password(hashed: str, password: str) -> bool:
|
def verify_password(hashed: str, password: str) -> bool:
|
||||||
"""Verify a password against a hash. Returns True if matched."""
|
"""Verify a password against a hash. Returns True if matched."""
|
||||||
try:
|
try:
|
||||||
ph.verify(hashed, password)
|
ph.verify(hashed, password)
|
||||||
# Note: We skip check_needs_rehash() for simplicity in this single-user app.
|
# Note: We skip check_needs_rehash() for simplicity in this single-user app.
|
||||||
return True
|
return True
|
||||||
except VerifyMismatchError:
|
except (InvalidHashError, VerifyMismatchError):
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
"""Session token generation and validation."""
|
"""Session token generation and validation."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Response
|
from fastapi import Response
|
||||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
def get_serializer() -> URLSafeTimedSerializer:
|
def get_serializer() -> URLSafeTimedSerializer:
|
||||||
"""Return a configured URLSafeTimedSerializer."""
|
"""Return a configured URLSafeTimedSerializer."""
|
||||||
return URLSafeTimedSerializer(settings.SECRET_KEY)
|
return URLSafeTimedSerializer(settings.SECRET_KEY)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ All settings are driven by environment variables (see .env.example).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@ Database engine, session management, and WAL mode setup.
|
||||||
SQLite with WAL mode for single-user concurrent read/write safety.
|
SQLite with WAL mode for single-user concurrent read/write safety.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import Generator
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import event, create_engine, Engine
|
from sqlalchemy import Engine, create_engine, event
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
@ -16,6 +15,7 @@ from app.config import settings
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
"""SQLAlchemy declarative base for all models."""
|
"""SQLAlchemy declarative base for all models."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -49,10 +49,10 @@ engine = create_db_engine()
|
||||||
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Session:
|
def get_db() -> Generator[Session, None, None]:
|
||||||
"""FastAPI dependency that yields a database session."""
|
"""FastAPI dependency that yields a database session."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db # type: ignore[misc]
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ This is the main entry point. The lifespan handler initializes the database
|
||||||
and ensures required directories exist on startup.
|
and ensures required directories exist on startup.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
|
|
@ -14,7 +14,8 @@ from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.api.v1 import router as v1_router
|
from app.api.v1 import router as v1_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import Base, engine
|
from app.db import Base, SessionLocal, engine
|
||||||
|
from app.services.trash_sweep import prune_auto_versions, sweep_trash
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -29,6 +30,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
settings.THUMBNAILS_PATH.mkdir(parents=True, exist_ok=True)
|
settings.THUMBNAILS_PATH.mkdir(parents=True, exist_ok=True)
|
||||||
settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Run retention cleanup on every startup. This keeps the single-container
|
||||||
|
# deployment self-maintaining without requiring a separate scheduler.
|
||||||
|
with SessionLocal() as db:
|
||||||
|
sweep_trash(db)
|
||||||
|
prune_auto_versions(db)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown: dispose of the engine
|
# Shutdown: dispose of the engine
|
||||||
|
|
@ -48,6 +55,7 @@ app = FastAPI(
|
||||||
|
|
||||||
# --- Error handlers ---
|
# --- Error handlers ---
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(404)
|
@app.exception_handler(404)
|
||||||
async def not_found_handler(request: Request, exc: Any) -> JSONResponse:
|
async def not_found_handler(request: Request, exc: Any) -> JSONResponse:
|
||||||
"""Consistent 404 error envelope."""
|
"""Consistent 404 error envelope."""
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ All models use UUIDv4 string primary keys (except the singleton settings row).
|
||||||
Timestamps are stored as ISO8601 TEXT columns.
|
Timestamps are stored as ISO8601 TEXT columns.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.models.settings import Settings
|
|
||||||
from app.models.document import Document
|
|
||||||
from app.models.annotation_state import AnnotationState
|
from app.models.annotation_state import AnnotationState
|
||||||
|
from app.models.document import Document
|
||||||
|
from app.models.settings import Settings
|
||||||
from app.models.version import Version
|
from app.models.version import Version
|
||||||
|
|
||||||
__all__ = ["Settings", "Document", "AnnotationState", "Version"]
|
__all__ = ["AnnotationState", "Document", "Settings", "Version"]
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
"""AnnotationState model — current working annotation layer per document."""
|
"""AnnotationState model — current working annotation layer per document."""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Text
|
from sqlalchemy import ForeignKey, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.document import Document
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
class AnnotationState(Base):
|
class AnnotationState(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
"""Document model — uploaded PDFs with soft-delete support."""
|
"""Document model — uploaded PDFs with soft-delete support."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import Index, Integer, Text
|
from sqlalchemy import Index, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.annotation_state import AnnotationState
|
||||||
|
from app.models.version import Version
|
||||||
|
|
||||||
|
|
||||||
def _uuid() -> str:
|
def _uuid() -> str:
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
class Document(Base):
|
class Document(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Settings model — singleton row for app-wide configuration."""
|
"""Settings model — singleton row for app-wide configuration."""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import Integer, Text
|
from sqlalchemy import Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
@ -22,10 +22,10 @@ class Settings(Base):
|
||||||
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
|
||||||
created_at: Mapped[str] = mapped_column(
|
created_at: Mapped[str] = mapped_column(
|
||||||
Text,
|
Text,
|
||||||
default=lambda: datetime.now(timezone.utc).isoformat(),
|
default=lambda: datetime.now(UTC).isoformat(),
|
||||||
)
|
)
|
||||||
updated_at: Mapped[str] = mapped_column(
|
updated_at: Mapped[str] = mapped_column(
|
||||||
Text,
|
Text,
|
||||||
default=lambda: datetime.now(timezone.utc).isoformat(),
|
default=lambda: datetime.now(UTC).isoformat(),
|
||||||
onupdate=lambda: datetime.now(timezone.utc).isoformat(),
|
onupdate=lambda: datetime.now(UTC).isoformat(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
"""Version model — annotation state snapshots for history/recovery."""
|
"""Version model — annotation state snapshots for history/recovery."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Index, Text
|
from sqlalchemy import ForeignKey, Index, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.document import Document
|
||||||
|
|
||||||
|
|
||||||
def _uuid() -> str:
|
def _uuid() -> str:
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
class Version(Base):
|
class Version(Base):
|
||||||
|
|
@ -40,6 +44,4 @@ class Version(Base):
|
||||||
# Relationships
|
# Relationships
|
||||||
document: Mapped["Document"] = relationship("Document", back_populates="versions")
|
document: Mapped["Document"] = relationship("Document", back_populates="versions")
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (Index("ix_versions_document_created", "document_id", "created_at"),)
|
||||||
Index("ix_versions_document_created", "document_id", "created_at"),
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal, Union, List, Tuple, Optional
|
from typing import Any, Literal
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class Rect(BaseModel):
|
class Rect(BaseModel):
|
||||||
x: float
|
x: float
|
||||||
y: float
|
y: float
|
||||||
width: float
|
width: float
|
||||||
height: float
|
height: float
|
||||||
|
|
||||||
|
|
||||||
class AnnotationBase(BaseModel):
|
class AnnotationBase(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
page: int = Field(ge=0)
|
page: int = Field(ge=0)
|
||||||
|
|
@ -19,6 +22,7 @@ class AnnotationBase(BaseModel):
|
||||||
createdAt: datetime
|
createdAt: datetime
|
||||||
updatedAt: datetime
|
updatedAt: datetime
|
||||||
|
|
||||||
|
|
||||||
class TextProps(BaseModel):
|
class TextProps(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
fontFamily: str = "Liberation Sans"
|
fontFamily: str = "Liberation Sans"
|
||||||
|
|
@ -28,82 +32,105 @@ class TextProps(BaseModel):
|
||||||
bold: bool = False
|
bold: bool = False
|
||||||
italic: bool = False
|
italic: bool = False
|
||||||
lineHeight: float = 1.2
|
lineHeight: float = 1.2
|
||||||
highlightColor: Optional[str] = None
|
highlightColor: str | None = None
|
||||||
styles: Optional[dict] = None
|
styles: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class TextAnnotation(AnnotationBase):
|
class TextAnnotation(AnnotationBase):
|
||||||
type: Literal["text"]
|
type: Literal["text"]
|
||||||
props: TextProps
|
props: TextProps
|
||||||
|
|
||||||
|
|
||||||
class DrawProps(BaseModel):
|
class DrawProps(BaseModel):
|
||||||
paths: List[Tuple[float, float]] = []
|
paths: list[tuple[float, float]] = Field(default_factory=list)
|
||||||
svgPath: Optional[str] = None
|
svgPath: str | None = None
|
||||||
strokeColor: str = "#000000"
|
strokeColor: str = "#000000"
|
||||||
strokeWidth: float = 2
|
strokeWidth: float = 2
|
||||||
opacity: float = 1.0
|
opacity: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
class DrawAnnotation(AnnotationBase):
|
class DrawAnnotation(AnnotationBase):
|
||||||
type: Literal["draw"]
|
type: Literal["draw"]
|
||||||
props: DrawProps
|
props: DrawProps
|
||||||
|
|
||||||
|
|
||||||
class SignatureDrawProps(BaseModel):
|
class SignatureDrawProps(BaseModel):
|
||||||
mode: Literal["draw"]
|
mode: Literal["draw"]
|
||||||
ref: str
|
ref: str
|
||||||
strokeColor: str = "#000000"
|
strokeColor: str = "#000000"
|
||||||
|
|
||||||
|
|
||||||
class SignatureTypeProps(BaseModel):
|
class SignatureTypeProps(BaseModel):
|
||||||
mode: Literal["type"]
|
mode: Literal["type"]
|
||||||
text: str
|
text: str
|
||||||
fontFamily: str
|
fontFamily: str
|
||||||
color: str = "#000000"
|
color: str = "#000000"
|
||||||
|
|
||||||
|
|
||||||
class SignatureAnnotation(AnnotationBase):
|
class SignatureAnnotation(AnnotationBase):
|
||||||
type: Literal["signature"]
|
type: Literal["signature"]
|
||||||
props: Union[SignatureDrawProps, SignatureTypeProps] = Field(discriminator="mode")
|
props: SignatureDrawProps | SignatureTypeProps = Field(discriminator="mode")
|
||||||
|
|
||||||
|
|
||||||
class ImageProps(BaseModel):
|
class ImageProps(BaseModel):
|
||||||
ref: str
|
ref: str
|
||||||
naturalWidth: float
|
naturalWidth: float
|
||||||
naturalHeight: float
|
naturalHeight: float
|
||||||
|
|
||||||
|
|
||||||
class ImageAnnotation(AnnotationBase):
|
class ImageAnnotation(AnnotationBase):
|
||||||
type: Literal["image"]
|
type: Literal["image"]
|
||||||
props: ImageProps
|
props: ImageProps
|
||||||
|
|
||||||
|
|
||||||
class HighlightProps(BaseModel):
|
class HighlightProps(BaseModel):
|
||||||
color: str = "#FFEB3B"
|
color: str = "#FFEB3B"
|
||||||
opacity: float = 0.3
|
opacity: float = 0.3
|
||||||
|
|
||||||
|
|
||||||
class HighlightAnnotation(AnnotationBase):
|
class HighlightAnnotation(AnnotationBase):
|
||||||
type: Literal["highlight"]
|
type: Literal["highlight"]
|
||||||
props: HighlightProps
|
props: HighlightProps
|
||||||
|
|
||||||
|
|
||||||
class ShapeProps(BaseModel):
|
class ShapeProps(BaseModel):
|
||||||
kind: Literal["rect", "ellipse", "line", "arrow"]
|
kind: Literal["rect", "ellipse", "line", "arrow"]
|
||||||
strokeColor: str = "#000000"
|
strokeColor: str = "#000000"
|
||||||
fillColor: str = "transparent"
|
fillColor: str = "transparent"
|
||||||
strokeWidth: float = 2
|
strokeWidth: float = 2
|
||||||
|
start: tuple[float, float] | None = None
|
||||||
|
end: tuple[float, float] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ShapeAnnotation(AnnotationBase):
|
class ShapeAnnotation(AnnotationBase):
|
||||||
type: Literal["shape"]
|
type: Literal["shape"]
|
||||||
props: ShapeProps
|
props: ShapeProps
|
||||||
|
|
||||||
Annotation = Union[
|
|
||||||
TextAnnotation,
|
KnownAnnotation = (
|
||||||
DrawAnnotation,
|
TextAnnotation
|
||||||
SignatureAnnotation,
|
| DrawAnnotation
|
||||||
ImageAnnotation,
|
| SignatureAnnotation
|
||||||
HighlightAnnotation,
|
| ImageAnnotation
|
||||||
ShapeAnnotation
|
| HighlightAnnotation
|
||||||
]
|
| ShapeAnnotation
|
||||||
|
)
|
||||||
|
|
||||||
|
# Working state is intentionally opaque. Keeping this payload as dictionaries
|
||||||
|
# lets newer clients round-trip annotation types that this server cannot export
|
||||||
|
# yet; the export registry skips those types with a warning.
|
||||||
|
AnnotationPayload = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class AnnotationStateResponse(BaseModel):
|
class AnnotationStateResponse(BaseModel):
|
||||||
data: List[Annotation]
|
data: list[AnnotationPayload]
|
||||||
updatedAt: datetime
|
updatedAt: datetime
|
||||||
|
|
||||||
|
|
||||||
class AnnotationStateUpdateRequest(BaseModel):
|
class AnnotationStateUpdateRequest(BaseModel):
|
||||||
data: List[Annotation]
|
data: list[AnnotationPayload]
|
||||||
baseUpdatedAt: datetime | None = None
|
baseUpdatedAt: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class AnnotationStateUpdateResponse(BaseModel):
|
class AnnotationStateUpdateResponse(BaseModel):
|
||||||
updatedAt: datetime
|
updatedAt: datetime
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class SetupRequest(BaseModel):
|
class SetupRequest(BaseModel):
|
||||||
password: str = Field(..., min_length=8)
|
password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""Pydantic schemas for Document APIs."""
|
"""Pydantic schemas for Document APIs."""
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
class DocumentMeta(BaseModel):
|
class DocumentMeta(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -12,7 +13,7 @@ class DocumentMeta(BaseModel):
|
||||||
in_trash: bool
|
in_trash: bool
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
deleted_at: Optional[str]
|
deleted_at: str | None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
@ -27,5 +28,5 @@ class BulkRestoreRequest(BaseModel):
|
||||||
ids: list[str]
|
ids: list[str]
|
||||||
|
|
||||||
class DocumentUpdateRequest(BaseModel):
|
class DocumentUpdateRequest(BaseModel):
|
||||||
title: Optional[str] = None
|
title: str | None = None
|
||||||
in_trash: Optional[bool] = None
|
in_trash: bool | None = None
|
||||||
|
|
|
||||||
8
backend/app/schemas/export.py
Normal file
8
backend/app/schemas/export.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
"""PDF export request schema."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ExportRequest(BaseModel):
|
||||||
|
versionId: str | None = None
|
||||||
|
flatten: bool = True
|
||||||
35
backend/app/schemas/versions.py
Normal file
35
backend/app/schemas/versions.py
Normal 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
|
||||||
12
backend/app/services/assets.py
Normal file
12
backend/app/services/assets.py
Normal 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)
|
||||||
1
backend/app/services/export/__init__.py
Normal file
1
backend/app/services/export/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""PDF export services."""
|
||||||
465
backend/app/services/export/renderer.py
Normal file
465
backend/app/services/export/renderer.py
Normal 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)
|
||||||
|
|
@ -1,53 +1,71 @@
|
||||||
"""Document storage service."""
|
"""Document and binary storage helpers."""
|
||||||
|
|
||||||
import shutil
|
|
||||||
import uuid
|
import uuid
|
||||||
import pymupdf
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import UploadFile, HTTPException
|
|
||||||
|
import pymupdf
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
def validate_pdf(filepath: Path) -> None:
|
_COPY_CHUNK_SIZE = 1024 * 1024
|
||||||
"""Validate PDF magic bytes and PyMuPDF openability."""
|
|
||||||
with open(filepath, "rb") as f:
|
|
||||||
header = f.read(5)
|
def validate_pdf(filepath: Path) -> int:
|
||||||
if header != b"%PDF-":
|
"""Validate a PDF's magic bytes and return its page count."""
|
||||||
|
with filepath.open("rb") as file:
|
||||||
|
if file.read(5) != b"%PDF-":
|
||||||
raise ValueError("Not a valid PDF file (missing magic bytes)")
|
raise ValueError("Not a valid PDF file (missing magic bytes)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = pymupdf.open(filepath)
|
with pymupdf.open(filepath) as document:
|
||||||
doc.close()
|
page_count = len(document)
|
||||||
except Exception as e:
|
except Exception as err:
|
||||||
raise ValueError(f"Failed to open PDF with PyMuPDF: {e}")
|
raise ValueError(f"Failed to open PDF with PyMuPDF: {err}") from err
|
||||||
|
|
||||||
|
if page_count == 0:
|
||||||
|
raise ValueError("PDF does not contain any pages")
|
||||||
|
return page_count
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(filename: str | None) -> str:
|
||||||
|
"""Return a display-safe basename without allowing path components."""
|
||||||
|
name = Path(filename or "Untitled.pdf").name.strip()
|
||||||
|
return name or "Untitled.pdf"
|
||||||
|
|
||||||
|
|
||||||
async def save_upload_file(upload_file: UploadFile) -> tuple[str, int, int]:
|
async def save_upload_file(upload_file: UploadFile) -> tuple[str, int, int]:
|
||||||
"""Save an uploaded file to disk and validate it."""
|
"""Stream an uploaded PDF to storage, enforcing the configured size limit."""
|
||||||
file_id = str(uuid.uuid4())
|
file_id = str(uuid.uuid4())
|
||||||
|
settings.PDF_STORAGE_PATH.mkdir(parents=True, exist_ok=True)
|
||||||
filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
|
filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
|
||||||
|
max_bytes = settings.MAX_UPLOAD_MB * 1024 * 1024
|
||||||
|
total_bytes = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(filepath, "wb") as f:
|
with filepath.open("wb") as destination:
|
||||||
shutil.copyfileobj(upload_file.file, f)
|
while chunk := upload_file.file.read(_COPY_CHUNK_SIZE):
|
||||||
except Exception as e:
|
total_bytes += len(chunk)
|
||||||
if filepath.exists():
|
if total_bytes > max_bytes:
|
||||||
filepath.unlink()
|
raise HTTPException(status_code=413, detail="File too large")
|
||||||
raise HTTPException(status_code=500, detail="Failed to save file")
|
destination.write(chunk)
|
||||||
|
except HTTPException:
|
||||||
|
filepath.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
except Exception as err:
|
||||||
|
filepath.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to save file") from err
|
||||||
|
|
||||||
page_count = 0
|
|
||||||
try:
|
try:
|
||||||
doc = pymupdf.open(filepath)
|
page_count = validate_pdf(filepath)
|
||||||
page_count = len(doc)
|
except ValueError as err:
|
||||||
doc.close()
|
filepath.unlink(missing_ok=True)
|
||||||
except Exception as e:
|
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||||
filepath.unlink()
|
|
||||||
raise HTTPException(status_code=400, detail=f"Failed to open PDF with PyMuPDF: {e}")
|
return file_id, total_bytes, page_count
|
||||||
|
|
||||||
size = filepath.stat().st_size
|
|
||||||
return file_id, size, page_count
|
|
||||||
|
|
||||||
def delete_pdf_file(file_id: str) -> None:
|
def delete_pdf_file(file_id: str) -> None:
|
||||||
"""Delete a PDF file from storage."""
|
"""Delete a PDF file from storage."""
|
||||||
filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
|
filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
|
||||||
if filepath.exists():
|
filepath.unlink(missing_ok=True)
|
||||||
filepath.unlink()
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
"""Thumbnail generation service."""
|
"""Thumbnail generation service."""
|
||||||
|
|
||||||
import pymupdf
|
import pymupdf
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
def generate_thumbnail(file_id: str) -> None:
|
|
||||||
|
def generate_thumbnail(file_id: str) -> bool:
|
||||||
"""Generate a PNG thumbnail for the first page of a PDF."""
|
"""Generate a PNG thumbnail for the first page of a PDF."""
|
||||||
pdf_path = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
|
pdf_path = settings.PDF_STORAGE_PATH / f"{file_id}.pdf"
|
||||||
thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png"
|
thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png"
|
||||||
|
|
||||||
if not pdf_path.exists():
|
if not pdf_path.exists():
|
||||||
return
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = pymupdf.open(pdf_path)
|
with pymupdf.open(pdf_path) as doc:
|
||||||
if len(doc) > 0:
|
if len(doc) == 0:
|
||||||
|
return False
|
||||||
page = doc[0]
|
page = doc[0]
|
||||||
# Zoom to approximately 600px width
|
# Zoom to approximately 600px width
|
||||||
rect = page.rect
|
rect = page.rect
|
||||||
|
|
@ -27,12 +28,13 @@ def generate_thumbnail(file_id: str) -> None:
|
||||||
|
|
||||||
# Save as PNG
|
# Save as PNG
|
||||||
pix.save(thumb_path, output="png")
|
pix.save(thumb_path, output="png")
|
||||||
doc.close()
|
return True
|
||||||
except Exception as e:
|
except Exception as err:
|
||||||
print(f"Failed to generate thumbnail for {file_id}: {e}")
|
print(f"Failed to generate thumbnail for {file_id}: {err}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def delete_thumbnail(file_id: str) -> None:
|
def delete_thumbnail(file_id: str) -> None:
|
||||||
"""Delete a thumbnail file."""
|
"""Delete a thumbnail file."""
|
||||||
thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png"
|
thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png"
|
||||||
if thumb_path.exists():
|
thumb_path.unlink(missing_ok=True)
|
||||||
thumb_path.unlink()
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,34 @@
|
||||||
"""Background tasks for sweeping trash and old versions."""
|
"""Background tasks for sweeping trash and old versions."""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db import get_db
|
from app.config import settings
|
||||||
from app.models.document import Document
|
|
||||||
from app.models.annotation_state import AnnotationState
|
from app.models.annotation_state import AnnotationState
|
||||||
|
from app.models.document import Document
|
||||||
from app.models.version import Version
|
from app.models.version import Version
|
||||||
|
from app.services.assets import delete_asset_directory
|
||||||
from app.services.storage import delete_pdf_file
|
from app.services.storage import delete_pdf_file
|
||||||
from app.services.thumbnails import delete_thumbnail
|
from app.services.thumbnails import delete_thumbnail
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
def sweep_trash(db: Session) -> None:
|
def sweep_trash(db: Session) -> None:
|
||||||
"""Permanently delete documents that have been in the trash past the retention period."""
|
"""Permanently delete documents that have been in the trash past the retention period."""
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.TRASH_RETENTION_DAYS)
|
cutoff = datetime.now(UTC) - timedelta(days=settings.TRASH_RETENTION_DAYS)
|
||||||
cutoff_iso = cutoff.isoformat()
|
cutoff_iso = cutoff.isoformat()
|
||||||
|
|
||||||
docs_to_delete = db.query(Document).filter(
|
docs_to_delete = (
|
||||||
Document.in_trash == True,
|
db.query(Document)
|
||||||
Document.deleted_at <= cutoff_iso
|
.filter(Document.deleted_at.is_not(None), Document.deleted_at <= cutoff_iso)
|
||||||
).all()
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
for doc in docs_to_delete:
|
for doc in docs_to_delete:
|
||||||
# Delete files from disk
|
# Delete files from disk
|
||||||
delete_pdf_file(doc.id)
|
delete_pdf_file(doc.id)
|
||||||
delete_thumbnail(doc.id)
|
delete_thumbnail(doc.id)
|
||||||
|
delete_asset_directory(doc.id)
|
||||||
|
|
||||||
# Delete DB associations
|
# Delete DB associations
|
||||||
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
|
db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete()
|
||||||
|
|
@ -35,14 +39,12 @@ def sweep_trash(db: Session) -> None:
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def prune_auto_versions(db: Session) -> None:
|
def prune_auto_versions(db: Session) -> None:
|
||||||
"""Delete automatic versions older than the retention period."""
|
"""Delete automatic versions older than the retention period."""
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.AUTO_VERSION_RETENTION_DAYS)
|
cutoff = datetime.now(UTC) - timedelta(days=settings.AUTO_VERSION_RETENTION_DAYS)
|
||||||
cutoff_iso = cutoff.isoformat()
|
cutoff_iso = cutoff.isoformat()
|
||||||
|
|
||||||
db.query(Version).filter(
|
db.query(Version).filter(Version.kind == "auto", Version.created_at <= cutoff_iso).delete()
|
||||||
Version.kind == "auto",
|
|
||||||
Version.created_at <= cutoff_iso
|
|
||||||
).delete()
|
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
121
backend/app/tests/test_api_workflows.py
Normal file
121
backend/app/tests/test_api_workflows.py
Normal 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)
|
||||||
121
backend/app/tests/test_export.py
Normal file
121
backend/app/tests/test_export.py
Normal 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
|
||||||
|
|
@ -30,12 +30,18 @@ dev = [
|
||||||
requires = ["setuptools>=75.0"]
|
requires = ["setuptools>=75.0"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app*"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py312"
|
target-version = "py312"
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
||||||
|
# FastAPI requires dependency and multipart declarations in parameter defaults;
|
||||||
|
# B008 treats that framework idiom as a mutable-default hazard.
|
||||||
|
ignore = ["B008"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.12"
|
python_version = "3.12"
|
||||||
|
|
@ -43,6 +49,21 @@ strict = true
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
|
|
||||||
|
# PyMuPDF does not ship complete type information. Keep strict checking for
|
||||||
|
# application code while isolating its untyped boundary and test fixtures.
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = [
|
||||||
|
"app.services.export.renderer",
|
||||||
|
"app.services.storage",
|
||||||
|
"app.services.thumbnails",
|
||||||
|
"app.api.v1.debug",
|
||||||
|
]
|
||||||
|
disable_error_code = ["no-untyped-call", "no-any-return", "no-untyped-def"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["app.tests.*"]
|
||||||
|
disable_error_code = ["no-untyped-call", "no-any-return", "no-untyped-def", "dict-item"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["app/tests"]
|
testpaths = ["app/tests"]
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,23 @@
|
||||||
services:
|
services:
|
||||||
backend:
|
paperjet:
|
||||||
build: ./backend
|
image: ${PAPERJET_IMAGE:-paperjet:local}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
environment:
|
environment:
|
||||||
- PAPERJET_SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
PAPERJET_SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting PaperJet}
|
||||||
- PAPERJET_MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200}
|
PAPERJET_MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-200}
|
||||||
- PAPERJET_TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30}
|
PAPERJET_TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
|
||||||
- PAPERJET_AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30}
|
PAPERJET_AUTO_VERSION_RETENTION_DAYS: ${AUTO_VERSION_RETENTION_DAYS:-30}
|
||||||
- PAPERJET_COOKIE_SECURE=${COOKIE_SECURE:-false}
|
PAPERJET_COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
- PAPERJET_DATABASE_PATH=/data/db/app.sqlite
|
PAPERJET_DATABASE_PATH: /data/db/app.sqlite
|
||||||
- PAPERJET_PDF_STORAGE_PATH=/data/pdfs
|
PAPERJET_PDF_STORAGE_PATH: /data/pdfs
|
||||||
- PAPERJET_THUMBNAILS_PATH=/data/thumbnails
|
PAPERJET_THUMBNAILS_PATH: /data/thumbnails
|
||||||
- PAPERJET_DEBUG=true
|
PAPERJET_DEBUG: ${DEBUG:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- pdf_storage:/data/pdfs
|
- pdf_storage:/data/pdfs
|
||||||
- thumbnails:/data/thumbnails
|
- thumbnails:/data/thumbnails
|
||||||
- db:/data/db
|
- db:/data/db
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build: ./frontend
|
|
||||||
depends_on: [backend]
|
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-4982}:80"
|
- "${HTTP_PORT:-4982}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
36
docker-entrypoint.sh
Normal file
36
docker-entrypoint.sh
Normal 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"
|
||||||
|
|
@ -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;"]
|
|
||||||
|
|
@ -1,73 +1,20 @@
|
||||||
# React + TypeScript + Vite
|
# PaperJet frontend
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
This directory contains the React/Vite client for PaperJet. Run the full stack
|
||||||
|
from the repository root; the root [README](../README.md) has Docker and local
|
||||||
|
development instructions.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
## Useful commands
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
```sh
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
npm ci
|
||||||
|
npm run dev # Vite with /api proxied to localhost:8000
|
||||||
## React Compiler
|
npm test -- --run
|
||||||
|
npm run typecheck
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
npm run lint
|
||||||
|
npm run build
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
The editor renders PDF pages with PDF.js and uses Fabric.js for the annotation
|
||||||
|
layer. Annotation state is persisted through the backend API; the browser does
|
||||||
```js
|
not write PDF bytes directly.
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
|
access_log /dev/stdout;
|
||||||
|
error_log /dev/stderr warn;
|
||||||
|
|
||||||
include /etc/nginx/mime.types;
|
include /etc/nginx/mime.types;
|
||||||
types {
|
types {
|
||||||
application/javascript mjs;
|
application/javascript mjs;
|
||||||
application/wasm wasm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Match MAX_UPLOAD_MB — raise in both this and the outer reverse proxy
|
# Match MAX_UPLOAD_MB — raise in both this and the outer reverse proxy
|
||||||
|
|
@ -25,9 +27,9 @@ server {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy /api/ to the backend container
|
# Proxy /api/ to the loopback-only FastAPI process in this container
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export const LoginForm = () => {
|
||||||
try {
|
try {
|
||||||
await login({ password })
|
await login({ password })
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Error is handled by the store
|
// Error is handled by the store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export const SetupForm = () => {
|
||||||
try {
|
try {
|
||||||
await setup({ password })
|
await setup({ password })
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Error is handled by the store
|
// Error is handled by the store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,17 @@ interface AuthState {
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function errorMessage(reason: unknown, fallback: string) {
|
||||||
|
if (reason instanceof Error && reason.message) return reason.message;
|
||||||
|
if (typeof reason === 'object' && reason !== null && 'error' in reason) {
|
||||||
|
const error = reason.error;
|
||||||
|
if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuth = create<AuthState>((set) => ({
|
export const useAuth = create<AuthState>((set) => ({
|
||||||
isInitialized: false,
|
isInitialized: false,
|
||||||
setupRequired: false,
|
setupRequired: false,
|
||||||
|
|
@ -33,9 +44,9 @@ export const useAuth = create<AuthState>((set) => ({
|
||||||
isInitialized: true,
|
isInitialized: true,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
})
|
})
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({
|
set({
|
||||||
error: err.error?.message || err.message || 'Failed to initialize',
|
error: errorMessage(err, 'Failed to initialize'),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isInitialized: true
|
isInitialized: true
|
||||||
})
|
})
|
||||||
|
|
@ -47,9 +58,9 @@ export const useAuth = create<AuthState>((set) => ({
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
await authApi.login(data)
|
await authApi.login(data)
|
||||||
set({ loggedIn: true, isLoading: false })
|
set({ loggedIn: true, isLoading: false })
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({
|
set({
|
||||||
error: err.error?.message || err.message || 'Failed to login',
|
error: errorMessage(err, 'Failed to login'),
|
||||||
isLoading: false
|
isLoading: false
|
||||||
})
|
})
|
||||||
throw err
|
throw err
|
||||||
|
|
@ -61,9 +72,9 @@ export const useAuth = create<AuthState>((set) => ({
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
await authApi.setup(data)
|
await authApi.setup(data)
|
||||||
set({ setupRequired: false, loggedIn: true, isLoading: false })
|
set({ setupRequired: false, loggedIn: true, isLoading: false })
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({
|
set({
|
||||||
error: err.error?.message || err.message || 'Failed to setup',
|
error: errorMessage(err, 'Failed to setup'),
|
||||||
isLoading: false
|
isLoading: false
|
||||||
})
|
})
|
||||||
throw err
|
throw err
|
||||||
|
|
@ -76,9 +87,9 @@ export const useAuth = create<AuthState>((set) => ({
|
||||||
await authApi.logout()
|
await authApi.logout()
|
||||||
set({ loggedIn: false, isLoading: false })
|
set({ loggedIn: false, isLoading: false })
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({
|
set({
|
||||||
error: err.error?.message || err.message || 'Failed to logout',
|
error: errorMessage(err, 'Failed to logout'),
|
||||||
isLoading: false
|
isLoading: false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import * as fabric from 'fabric';
|
import * as fabric from 'fabric';
|
||||||
import { useEditorStore } from '../store';
|
import { useEditorStore } from '../store';
|
||||||
import { getTool } from '../../../lib/annotations/registry';
|
import { getTool } from '../../../lib/annotations/registry';
|
||||||
import type { ViewportParams } from '../../../lib/coords';
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
import { screenToPdf } from '../../../lib/coords';
|
import { screenRectToPdf } from '../../../lib/coords';
|
||||||
import { TextFormatToolbar } from '../toolbar/TextFormatToolbar';
|
import { TextFormatToolbar } from '../toolbar/TextFormatToolbar';
|
||||||
|
|
||||||
interface AnnotationLayerProps {
|
interface AnnotationLayerProps {
|
||||||
|
|
@ -13,208 +13,201 @@ interface AnnotationLayerProps {
|
||||||
viewportParams: ViewportParams;
|
viewportParams: ViewportParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AnnotationObject = fabric.FabricObject & { id?: string };
|
||||||
|
|
||||||
export function AnnotationLayer({ pageNumber, width, height, viewportParams }: AnnotationLayerProps) {
|
export function AnnotationLayer({ pageNumber, width, height, viewportParams }: AnnotationLayerProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const fabricRef = useRef<fabric.Canvas | null>(null);
|
const fabricRef = useRef<fabric.Canvas | null>(null);
|
||||||
|
const renderingIds = useRef(new Set<string>());
|
||||||
const activeToolName = useEditorStore(state => state.activeTool);
|
const renderGeneration = useRef(0);
|
||||||
|
|
||||||
const annotations = useEditorStore(state => state.annotations);
|
|
||||||
|
|
||||||
const latestViewportParams = useRef(viewportParams);
|
const latestViewportParams = useRef(viewportParams);
|
||||||
|
const previousDimensions = useRef({ width, height });
|
||||||
|
const [canvasInstance, setCanvasInstance] = useState<fabric.Canvas | null>(null);
|
||||||
|
|
||||||
|
const activeToolName = useEditorStore((state) => state.activeTool);
|
||||||
|
const annotations = useEditorStore((state) => state.annotations);
|
||||||
|
const selection = useEditorStore((state) => state.selection);
|
||||||
|
const draftAnnotation = useEditorStore((state) => state.draftAnnotation);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestViewportParams.current = viewportParams;
|
latestViewportParams.current = viewportParams;
|
||||||
}, [viewportParams]);
|
}, [viewportParams]);
|
||||||
|
|
||||||
// Initial setup and event binding
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canvasRef.current) return;
|
if (!canvasRef.current) return;
|
||||||
|
const pendingRenderIds = renderingIds.current;
|
||||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
selection: activeToolName === 'select',
|
selection: activeToolName === 'select',
|
||||||
|
enableRetinaScaling: true,
|
||||||
|
});
|
||||||
|
fabricRef.current = canvas;
|
||||||
|
setCanvasInstance(canvas);
|
||||||
|
|
||||||
|
canvas.on('object:modified', (event) => {
|
||||||
|
const object = event.target as AnnotationObject | undefined;
|
||||||
|
if (!object?.id) return;
|
||||||
|
const annotation = useEditorStore.getState().annotations.find((item) => item.id === object.id);
|
||||||
|
if (!annotation) return;
|
||||||
|
const bounds = object.getBoundingRect();
|
||||||
|
useEditorStore.getState().updateAnnotation(object.id, {
|
||||||
|
rect: screenRectToPdf(
|
||||||
|
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
|
||||||
|
latestViewportParams.current,
|
||||||
|
),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
(window as any).__fabricCanvas = canvas;
|
const updateSelection = () => {
|
||||||
|
const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
|
||||||
// Sync fabric modifications back to Zustand
|
useEditorStore.getState().setSelection(activeObject?.id ?? null);
|
||||||
canvas.on('object:modified', (e) => {
|
|
||||||
const obj = e.target as any;
|
|
||||||
if (obj && obj.id) {
|
|
||||||
// Find existing annotation
|
|
||||||
const ann = useEditorStore.getState().annotations.find(a => a.id === obj.id);
|
|
||||||
if (ann) {
|
|
||||||
const currentVp = latestViewportParams.current;
|
|
||||||
const pt = screenToPdf({ x: obj.left, y: obj.top }, currentVp);
|
|
||||||
|
|
||||||
const scaleX = obj.scaleX || 1;
|
|
||||||
const scaleY = obj.scaleY || 1;
|
|
||||||
|
|
||||||
// Box width/height in PDF space
|
|
||||||
const newWidth = (obj.width * scaleX) / (currentVp.scale * (currentVp.dpr || 1));
|
|
||||||
const newHeight = (obj.height * scaleY) / (currentVp.scale * (currentVp.dpr || 1));
|
|
||||||
|
|
||||||
useEditorStore.getState().updateAnnotation(obj.id, {
|
|
||||||
rect: { ...ann.rect, x: pt.x, y: pt.y, width: newWidth, height: newHeight }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateSelection = (_e?: any) => {
|
|
||||||
const activeObj = canvas.getActiveObject() as any;
|
|
||||||
if (activeObj && activeObj.id) {
|
|
||||||
useEditorStore.getState().setSelection(activeObj.id);
|
|
||||||
} else {
|
|
||||||
useEditorStore.getState().setSelection(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
canvas.on('selection:created', updateSelection);
|
canvas.on('selection:created', updateSelection);
|
||||||
canvas.on('selection:updated', updateSelection);
|
canvas.on('selection:updated', updateSelection);
|
||||||
canvas.on('selection:cleared', updateSelection);
|
canvas.on('selection:cleared', updateSelection);
|
||||||
|
|
||||||
// Keyboard shortcut for delete
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
|
||||||
// Don't intercept if user is typing in an input field outside of canvas
|
if (event.key !== 'Delete' && event.key !== 'Backspace') return;
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
|
||||||
return;
|
if (activeObject && 'isEditing' in activeObject && activeObject.isEditing) return;
|
||||||
|
const activeObjects = canvas.getActiveObjects() as AnnotationObject[];
|
||||||
|
if (!activeObjects.length) return;
|
||||||
|
for (const object of activeObjects) {
|
||||||
|
if (object.id) useEditorStore.getState().deleteAnnotation(object.id);
|
||||||
}
|
}
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
||||||
const activeObj = canvas.getActiveObject() as any;
|
|
||||||
if (activeObj && activeObj.isEditing === true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeObjects = canvas.getActiveObjects();
|
|
||||||
if (activeObjects && activeObjects.length > 0) {
|
|
||||||
activeObjects.forEach((obj: any) => {
|
|
||||||
if (obj.id) {
|
|
||||||
useEditorStore.getState().deleteAnnotation(obj.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
canvas.discardActiveObject();
|
canvas.discardActiveObject();
|
||||||
canvas.requestRenderAll();
|
canvas.requestRenderAll();
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
fabricRef.current = canvas;
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
pendingRenderIds.clear();
|
||||||
|
renderGeneration.current += 1;
|
||||||
canvas.dispose();
|
canvas.dispose();
|
||||||
fabricRef.current = null;
|
fabricRef.current = null;
|
||||||
|
setCanvasInstance(null);
|
||||||
};
|
};
|
||||||
}, []); // Run once on mount
|
// The canvas is created once for this page. Dimension and tool changes are
|
||||||
|
// handled by the effects below.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const prevScaleRef = useRef(viewportParams.scale);
|
|
||||||
|
|
||||||
// Handle dimensions and re-rendering annotations
|
|
||||||
useEffect(() => {
|
|
||||||
if (!fabricRef.current) return;
|
|
||||||
const canvas = fabricRef.current;
|
|
||||||
canvas.setDimensions({ width, height });
|
|
||||||
|
|
||||||
const activeObj = canvas.getActiveObject() as any;
|
|
||||||
const activeId = activeObj?.id;
|
|
||||||
|
|
||||||
const pageAnns = annotations.filter(a => a.page === pageNumber);
|
|
||||||
const annIds = new Set(pageAnns.map(a => a.id));
|
|
||||||
|
|
||||||
const scaleChanged = prevScaleRef.current !== viewportParams.scale;
|
|
||||||
prevScaleRef.current = viewportParams.scale;
|
|
||||||
|
|
||||||
// Remove objects that are no longer in annotations, OR if the zoom scale changed
|
|
||||||
canvas.getObjects().forEach((obj: any) => {
|
|
||||||
// Don't remove the active object unless it was deleted from the store,
|
|
||||||
// UNLESS the scale changed (we must recreate everything on zoom)
|
|
||||||
if (!scaleChanged && obj.id === activeId && annIds.has(activeId)) return;
|
|
||||||
|
|
||||||
if (scaleChanged || !annIds.has(obj.id)) {
|
|
||||||
canvas.remove(obj);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add missing annotations
|
|
||||||
const canvasObjIds = new Set(canvas.getObjects().map((o: any) => o.id));
|
|
||||||
pageAnns.forEach(ann => {
|
|
||||||
if (!scaleChanged && ann.id === activeId) return; // Skip rendering the active object
|
|
||||||
|
|
||||||
if (!canvasObjIds.has(ann.id)) {
|
|
||||||
const tool = getTool(ann.type);
|
|
||||||
if (tool && tool.renderToFabric) {
|
|
||||||
tool.renderToFabric(ann, canvas, viewportParams);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (scaleChanged && activeId && annIds.has(activeId)) {
|
|
||||||
const newlyRenderedActiveObj = canvas.getObjects().find((o: any) => o.id === activeId);
|
|
||||||
if (newlyRenderedActiveObj) {
|
|
||||||
canvas.setActiveObject(newlyRenderedActiveObj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [width, height, annotations, pageNumber, viewportParams.scale]);
|
|
||||||
|
|
||||||
// Handle tool activation/deactivation and event binding
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = fabricRef.current;
|
const canvas = fabricRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
const generation = ++renderGeneration.current;
|
||||||
|
renderingIds.current.clear();
|
||||||
|
canvas.setDimensions({ width, height });
|
||||||
|
|
||||||
// First deactivate any previous tool logic
|
const pageAnnotations = annotations.filter((annotation) => annotation.page === pageNumber);
|
||||||
// This is a bit tricky if we don't remember the previous tool,
|
const annotationIds = new Set(pageAnnotations.map((annotation) => annotation.id));
|
||||||
// but we can just clear event listeners.
|
const activeObject = canvas.getActiveObject() as AnnotationObject | undefined;
|
||||||
|
const activeId = activeObject?.id;
|
||||||
|
const scaleChanged = previousDimensions.current.width !== width || previousDimensions.current.height !== height;
|
||||||
|
previousDimensions.current = { width, height };
|
||||||
|
|
||||||
|
for (const object of canvas.getObjects() as AnnotationObject[]) {
|
||||||
|
if (!object.id || !annotationIds.has(object.id) || scaleChanged) {
|
||||||
|
canvas.remove(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const annotation of pageAnnotations) {
|
||||||
|
if (annotation.id === activeId && !scaleChanged) continue;
|
||||||
|
if (canvas.getObjects().some((object) => (object as AnnotationObject).id === annotation.id)) continue;
|
||||||
|
if (renderingIds.current.has(annotation.id)) continue;
|
||||||
|
const tool = getTool(annotation.type);
|
||||||
|
if (!tool?.renderToFabric) continue;
|
||||||
|
renderingIds.current.add(annotation.id);
|
||||||
|
const objectsBeforeRender = new Set(canvas.getObjects());
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(() => tool.renderToFabric?.(annotation, canvas, viewportParams))
|
||||||
|
.then(() => {
|
||||||
|
const addedObjects = canvas.getObjects().filter(
|
||||||
|
(object) => !objectsBeforeRender.has(object) && (object as AnnotationObject).id === annotation.id,
|
||||||
|
);
|
||||||
|
if (generation !== renderGeneration.current) {
|
||||||
|
for (const object of addedObjects) canvas.remove(object);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeId) {
|
||||||
|
const renderedActive = canvas.getObjects().find(
|
||||||
|
(object) => (object as AnnotationObject).id === activeId,
|
||||||
|
);
|
||||||
|
if (renderedActive) canvas.setActiveObject(renderedActive);
|
||||||
|
}
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(`Failed to render ${annotation.type} annotation`, error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
renderingIds.current.delete(annotation.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeId) {
|
||||||
|
const renderedActive = canvas.getObjects().find(
|
||||||
|
(object) => (object as AnnotationObject).id === activeId,
|
||||||
|
);
|
||||||
|
if (renderedActive) canvas.setActiveObject(renderedActive);
|
||||||
|
}
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
}, [annotations, height, pageNumber, viewportParams, width]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = fabricRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
canvas.off('mouse:down');
|
canvas.off('mouse:down');
|
||||||
canvas.off('mouse:move');
|
canvas.off('mouse:move');
|
||||||
canvas.off('mouse:up');
|
canvas.off('mouse:up');
|
||||||
|
canvas.off('path:created');
|
||||||
canvas.selection = activeToolName === 'select';
|
canvas.selection = activeToolName === 'select';
|
||||||
|
|
||||||
const tool = getTool(activeToolName);
|
const tool = getTool(activeToolName);
|
||||||
if (tool) {
|
if (!tool) return;
|
||||||
if (tool.onActivate) tool.onActivate(canvas);
|
tool.onActivate?.(canvas);
|
||||||
|
|
||||||
if (tool.onPointerDown) {
|
if (tool.onPointerDown) {
|
||||||
canvas.on('mouse:down', (e) => tool.onPointerDown!(e, canvas, latestViewportParams.current, pageNumber));
|
canvas.on('mouse:down', (event) =>
|
||||||
|
tool.onPointerDown?.(event, canvas, latestViewportParams.current, pageNumber),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (tool.onPointerMove) {
|
if (tool.onPointerMove) {
|
||||||
canvas.on('mouse:move', (e) => tool.onPointerMove!(e, canvas, latestViewportParams.current, pageNumber));
|
canvas.on('mouse:move', (event) =>
|
||||||
|
tool.onPointerMove?.(event, canvas, latestViewportParams.current, pageNumber),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (tool.onPointerUp) {
|
if (tool.onPointerUp) {
|
||||||
canvas.on('mouse:up', (e) => tool.onPointerUp!(e, canvas, latestViewportParams.current, pageNumber));
|
canvas.on('mouse:up', (event) =>
|
||||||
|
tool.onPointerUp?.(event, canvas, latestViewportParams.current, pageNumber),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (tool.onPathCreated) {
|
if (tool.onPathCreated) {
|
||||||
canvas.on('path:created', (e) => tool.onPathCreated!(e, canvas, latestViewportParams.current, pageNumber));
|
canvas.on('path:created', (event) =>
|
||||||
}
|
tool.onPathCreated?.(event, canvas, latestViewportParams.current, pageNumber),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return () => tool.onDeactivate?.(canvas);
|
||||||
|
}, [activeToolName, pageNumber]);
|
||||||
|
|
||||||
return () => {
|
const activeAnnotation =
|
||||||
if (tool && tool.onDeactivate) {
|
draftAnnotation?.page === pageNumber
|
||||||
tool.onDeactivate(canvas);
|
? draftAnnotation
|
||||||
}
|
: annotations.find((annotation) => annotation.id === selection && annotation.page === pageNumber);
|
||||||
};
|
|
||||||
}, [activeToolName]);
|
|
||||||
|
|
||||||
const selection = useEditorStore(state => state.selection);
|
|
||||||
const draftAnnotation = useEditorStore(state => state.draftAnnotation);
|
|
||||||
const isSelectedOnThisPage = annotations.find(a => a.id === selection && a.page === pageNumber);
|
|
||||||
const isDraftOnThisPage = draftAnnotation?.page === pageNumber;
|
|
||||||
const activeAnn = isDraftOnThisPage ? draftAnnotation : isSelectedOnThisPage;
|
|
||||||
const toolbarAnnotationId = activeAnn ? activeAnn.id : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-0 left-0" style={{ width, height }}>
|
<div className="absolute left-0 top-0" style={{ width, height }}>
|
||||||
<canvas ref={canvasRef} />
|
<canvas ref={canvasRef} />
|
||||||
{toolbarAnnotationId && activeAnn && (
|
{activeAnnotation?.type === 'text' && canvasInstance && (
|
||||||
<>
|
<TextFormatToolbar
|
||||||
{activeAnn.type === 'text' && <TextFormatToolbar annotationId={toolbarAnnotationId} viewportParams={viewportParams} />}
|
annotationId={activeAnnotation.id}
|
||||||
{/* We will add ShapeControls and DrawControls here once they are implemented */}
|
viewportParams={viewportParams}
|
||||||
</>
|
canvas={canvasInstance}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,127 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
|
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
|
||||||
import { AnnotationLayer } from '../canvas/AnnotationLayer';
|
import { AnnotationLayer } from '../canvas/AnnotationLayer';
|
||||||
|
|
||||||
interface PageRendererProps {
|
interface PageRendererProps {
|
||||||
pdfDoc: PDFDocumentProxy;
|
pdfDoc: PDFDocumentProxy;
|
||||||
pageNumber: number;
|
pageIndex: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
dpr: number;
|
dpr: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageRenderer({ pdfDoc, pageNumber, scale, dpr }: PageRendererProps) {
|
export function PageRenderer({ pdfDoc, pageIndex, scale, dpr }: PageRendererProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const [page, setPage] = useState<PDFPageProxy | null>(null);
|
const [page, setPage] = useState<PDFPageProxy | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
pdfDoc.getPage(pageNumber).then(p => {
|
const pageNumber = pageIndex + 1;
|
||||||
if (active) setPage(p);
|
pdfDoc.getPage(pageNumber).then((nextPage) => {
|
||||||
|
if (active) setPage(nextPage);
|
||||||
});
|
});
|
||||||
return () => { active = false; };
|
|
||||||
}, [pdfDoc, pageNumber]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!page || !canvasRef.current) return;
|
|
||||||
|
|
||||||
const viewport = page.getViewport({ scale: scale * dpr });
|
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) return;
|
|
||||||
|
|
||||||
canvas.width = viewport.width;
|
|
||||||
canvas.height = viewport.height;
|
|
||||||
|
|
||||||
canvas.style.width = `${viewport.width / dpr}px`;
|
|
||||||
canvas.style.height = `${viewport.height / dpr}px`;
|
|
||||||
|
|
||||||
const renderContext = {
|
|
||||||
canvas: canvas,
|
|
||||||
viewport: viewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
let renderTask = page.render(renderContext);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
renderTask.cancel();
|
active = false;
|
||||||
};
|
};
|
||||||
}, [page, scale, dpr]);
|
}, [pdfDoc, pageIndex]);
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-white shadow-sm flex items-center justify-center text-neutral-400 border border-neutral-200"
|
className="flex items-center justify-center border border-neutral-200 bg-white text-neutral-400 shadow-sm"
|
||||||
style={{ width: 612 * scale, height: 792 * scale }}
|
style={{ width: 612 * scale, height: 792 * scale }}
|
||||||
>
|
>
|
||||||
Loading page {pageNumber}...
|
Loading page {pageIndex + 1}...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseViewport = page ? page.getViewport({ scale: 1, rotation: 0 }) : null;
|
const canonicalViewport = page.getViewport({ scale: 1, rotation: 0 });
|
||||||
|
const renderedViewport = page.getViewport({
|
||||||
|
scale: scale * dpr,
|
||||||
|
rotation: page.rotate,
|
||||||
|
});
|
||||||
|
const cssWidth = renderedViewport.width / dpr;
|
||||||
|
const cssHeight = renderedViewport.height / dpr;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative shadow-xl bg-white border border-neutral-200 group">
|
<RenderedPage
|
||||||
<canvas ref={canvasRef} className="block" />
|
page={page}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
scale={scale}
|
||||||
|
dpr={dpr}
|
||||||
|
canonicalWidth={canonicalViewport.width}
|
||||||
|
canonicalHeight={canonicalViewport.height}
|
||||||
|
renderedWidth={renderedViewport.width}
|
||||||
|
renderedHeight={renderedViewport.height}
|
||||||
|
cssWidth={cssWidth}
|
||||||
|
cssHeight={cssHeight}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{baseViewport && (
|
interface RenderedPageProps {
|
||||||
<AnnotationLayer
|
page: PDFPageProxy;
|
||||||
pageNumber={pageNumber}
|
pageIndex: number;
|
||||||
width={baseViewport.width * scale}
|
scale: number;
|
||||||
height={baseViewport.height * scale}
|
dpr: number;
|
||||||
viewportParams={{
|
canonicalWidth: number;
|
||||||
|
canonicalHeight: number;
|
||||||
|
renderedWidth: number;
|
||||||
|
renderedHeight: number;
|
||||||
|
cssWidth: number;
|
||||||
|
cssHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenderedPage({
|
||||||
|
page,
|
||||||
|
pageIndex,
|
||||||
|
scale,
|
||||||
|
dpr,
|
||||||
|
canonicalWidth,
|
||||||
|
canonicalHeight,
|
||||||
|
renderedWidth,
|
||||||
|
renderedHeight,
|
||||||
|
cssWidth,
|
||||||
|
cssHeight,
|
||||||
|
}: RenderedPageProps) {
|
||||||
|
const viewportParams = useMemo(() => ({
|
||||||
scale,
|
scale,
|
||||||
rotation: page.rotate,
|
rotation: page.rotate,
|
||||||
canonicalWidth: baseViewport.width,
|
canonicalWidth,
|
||||||
canonicalHeight: baseViewport.height,
|
canonicalHeight,
|
||||||
dpr
|
dpr,
|
||||||
}}
|
}), [canonicalHeight, canonicalWidth, dpr, page.rotate, scale]);
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="absolute -left-10 top-2 text-xs font-bold text-neutral-400 bg-white/80 rounded-md px-1.5 py-0.5 shadow-sm border border-neutral-200">
|
useEffect(() => {
|
||||||
{pageNumber}
|
const canvas = document.querySelector<HTMLCanvasElement>(
|
||||||
|
`[data-paperjet-page="${pageIndex}"]`,
|
||||||
|
);
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return;
|
||||||
|
canvas.width = renderedWidth;
|
||||||
|
canvas.height = renderedHeight;
|
||||||
|
canvas.style.width = `${cssWidth}px`;
|
||||||
|
canvas.style.height = `${cssHeight}px`;
|
||||||
|
|
||||||
|
const renderTask = page.render({ canvas, viewport: page.getViewport({ scale: scale * dpr, rotation: page.rotate }) });
|
||||||
|
renderTask.promise.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
renderTask.cancel();
|
||||||
|
};
|
||||||
|
}, [page, pageIndex, scale, dpr, renderedWidth, renderedHeight, cssWidth, cssHeight]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative border border-neutral-200 bg-white shadow-xl" style={{ width: cssWidth, height: cssHeight }}>
|
||||||
|
<canvas data-paperjet-page={pageIndex} className="block" />
|
||||||
|
<AnnotationLayer
|
||||||
|
pageNumber={pageIndex}
|
||||||
|
width={cssWidth}
|
||||||
|
height={cssHeight}
|
||||||
|
viewportParams={viewportParams}
|
||||||
|
/>
|
||||||
|
<div className="absolute -left-10 top-2 rounded-md border border-neutral-200 bg-white/80 px-1.5 py-0.5 text-xs font-bold text-neutral-400 shadow-sm">
|
||||||
|
{pageIndex + 1}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ interface PageStackProps {
|
||||||
|
|
||||||
export function PageStack({ pdfDoc }: PageStackProps) {
|
export function PageStack({ pdfDoc }: PageStackProps) {
|
||||||
const numPages = pdfDoc.numPages;
|
const numPages = pdfDoc.numPages;
|
||||||
const pages = Array.from({ length: numPages }, (_, i) => i + 1);
|
const pages = Array.from({ length: numPages }, (_, i) => i);
|
||||||
|
|
||||||
const zoom = useEditorStore(state => state.zoom);
|
const zoom = useEditorStore(state => state.zoom);
|
||||||
|
|
||||||
// For now, render all pages vertically. Virtualization comes in Phase 7.
|
// PDF points map directly to CSS pixels at 100%. Device pixel ratio is
|
||||||
const scale = zoom * 2.0;
|
// applied only to the PDF.js backing canvas, not the annotation overlay.
|
||||||
|
const scale = zoom;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -22,7 +23,7 @@ export function PageStack({ pdfDoc }: PageStackProps) {
|
||||||
<PageRenderer
|
<PageRenderer
|
||||||
key={pageNum}
|
key={pageNum}
|
||||||
pdfDoc={pdfDoc}
|
pdfDoc={pdfDoc}
|
||||||
pageNumber={pageNum}
|
pageIndex={pageNum}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
dpr={dpr}
|
dpr={dpr}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -4,50 +4,61 @@ import { PageStack } from './PageStack';
|
||||||
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||||
'pdfjs-dist/build/pdf.worker.mjs',
|
'pdfjs-dist/build/pdf.worker.mjs',
|
||||||
import.meta.url
|
import.meta.url,
|
||||||
).toString();
|
).toString();
|
||||||
|
|
||||||
|
interface LoadedDocument {
|
||||||
|
id: string;
|
||||||
|
document: pdfjsLib.PDFDocumentProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentError {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function PdfDocument({ documentId }: { documentId: string }) {
|
export function PdfDocument({ documentId }: { documentId: string }) {
|
||||||
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
|
const [loaded, setLoaded] = useState<LoadedDocument | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<DocumentError | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const url = `/api/v1/documents/${documentId}/file`;
|
const loadingTask = pdfjsLib.getDocument({ url: `/api/v1/documents/${documentId}/file` });
|
||||||
|
|
||||||
const loadingTask = pdfjsLib.getDocument({ url });
|
loadingTask.promise.then((document) => {
|
||||||
|
if (active) setLoaded({ id: documentId, document });
|
||||||
loadingTask.promise.then((doc) => {
|
}).catch((reason: unknown) => {
|
||||||
if (active) setPdfDoc(doc);
|
if (active) {
|
||||||
}).catch(err => {
|
const message = reason instanceof Error ? reason.message : 'Unable to load this PDF.';
|
||||||
console.error('Failed to load PDF', err);
|
setError({ id: documentId, message });
|
||||||
if (active) setError(err.message);
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
loadingTask.destroy();
|
void loadingTask.destroy();
|
||||||
};
|
};
|
||||||
}, [documentId]);
|
}, [documentId]);
|
||||||
|
|
||||||
if (error) {
|
const currentError = error?.id === documentId ? error.message : null;
|
||||||
|
const currentDocument = loaded?.id === documentId ? loaded.document : null;
|
||||||
|
|
||||||
|
if (currentError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full w-full">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="text-red-500 bg-red-50 px-4 py-3 rounded-lg border border-red-200">
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-600">Error loading PDF: {currentError}</div>
|
||||||
Error loading PDF: {error}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pdfDoc) {
|
if (!currentDocument) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full w-full gap-4">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500"></div>
|
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-accent-500" />
|
||||||
<div className="text-neutral-500 font-medium animate-pulse">Loading document...</div>
|
<div className="font-medium animate-pulse text-neutral-500">Loading document…</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PageStack pdfDoc={pdfDoc} />;
|
return <PageStack pdfDoc={currentDocument} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
frontend/src/features/editor/store.test.ts
Normal file
56
frontend/src/features/editor/store.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { useEditorStore } from './store';
|
||||||
|
import type { TextAnnotation } from '../../lib/annotations/types';
|
||||||
|
|
||||||
|
const annotation: TextAnnotation = {
|
||||||
|
id: 'note-1',
|
||||||
|
page: 0,
|
||||||
|
type: 'text',
|
||||||
|
rect: { x: 10, y: 20, width: 100, height: 30 },
|
||||||
|
rotation: 0,
|
||||||
|
z: 0,
|
||||||
|
props: {
|
||||||
|
text: 'Original',
|
||||||
|
fontFamily: 'Liberation Sans',
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#000000',
|
||||||
|
align: 'left',
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
},
|
||||||
|
createdAt: '2026-08-14T00:00:00Z',
|
||||||
|
updatedAt: '2026-08-14T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.getState().setDocumentId(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editor history', () => {
|
||||||
|
it('undoes and redoes annotation mutations without sharing mutable state', () => {
|
||||||
|
const store = useEditorStore.getState();
|
||||||
|
store.setDocumentId('document-1');
|
||||||
|
store.addAnnotation(annotation);
|
||||||
|
store.updateAnnotation('note-1', { props: { ...annotation.props, text: 'Updated' } });
|
||||||
|
|
||||||
|
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Updated' });
|
||||||
|
useEditorStore.getState().undo();
|
||||||
|
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Original' });
|
||||||
|
useEditorStore.getState().redo();
|
||||||
|
expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Updated' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears working state and history when switching documents', () => {
|
||||||
|
const store = useEditorStore.getState();
|
||||||
|
store.setDocumentId('document-1');
|
||||||
|
store.addAnnotation(annotation);
|
||||||
|
store.setDocumentId('document-2');
|
||||||
|
|
||||||
|
const next = useEditorStore.getState();
|
||||||
|
expect(next.documentId).toBe('document-2');
|
||||||
|
expect(next.annotations).toEqual([]);
|
||||||
|
expect(next.past).toEqual([]);
|
||||||
|
expect(next.future).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,74 +1,171 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Annotation, TextProps } from '../../lib/annotations/types';
|
import type {
|
||||||
|
Annotation,
|
||||||
|
SignatureDrawProps,
|
||||||
|
SignatureTypeProps,
|
||||||
|
TextProps,
|
||||||
|
} from '../../lib/annotations/types';
|
||||||
|
|
||||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
export type SaveStatus = 'idle' | 'loading' | 'saving' | 'saved' | 'error';
|
||||||
|
type SignatureProps = SignatureDrawProps | SignatureTypeProps;
|
||||||
|
type PendingImage = { ref: string; width: number; height: number };
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
documentId: string | null;
|
documentId: string | null;
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
activeTool: string;
|
past: Annotation[][];
|
||||||
|
future: Annotation[][];
|
||||||
selection: string | null;
|
selection: string | null;
|
||||||
|
activeTool: string;
|
||||||
|
activeShapeKind: 'rect' | 'ellipse' | 'line' | 'arrow';
|
||||||
zoom: number;
|
zoom: number;
|
||||||
saveStatus: SaveStatus;
|
saveStatus: SaveStatus;
|
||||||
|
annotationUpdatedAt: string | null;
|
||||||
defaultTextProps: Partial<TextProps>;
|
defaultTextProps: Partial<TextProps>;
|
||||||
draftAnnotation: Annotation | null;
|
draftAnnotation: Annotation | null;
|
||||||
isSignatureModalOpen: boolean;
|
isSignatureModalOpen: boolean;
|
||||||
pendingSignatureProps: any | null;
|
pendingSignatureProps: SignatureProps | null;
|
||||||
pendingImageRef: { ref: string, width: number, height: number } | null;
|
pendingImageRef: PendingImage | null;
|
||||||
|
|
||||||
// Actions
|
|
||||||
setDocumentId: (id: string | null) => void;
|
setDocumentId: (id: string | null) => void;
|
||||||
setAnnotations: (annotations: Annotation[]) => void;
|
setAnnotations: (annotations: Annotation[]) => void;
|
||||||
|
setAnnotationUpdatedAt: (updatedAt: string | null) => void;
|
||||||
addAnnotation: (annotation: Annotation) => void;
|
addAnnotation: (annotation: Annotation) => void;
|
||||||
updateAnnotation: (id: string, updates: Partial<Annotation>) => void;
|
updateAnnotation: (id: string, updates: Partial<Annotation>) => void;
|
||||||
deleteAnnotation: (id: string) => void;
|
deleteAnnotation: (id: string) => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
canUndo: () => boolean;
|
||||||
|
canRedo: () => boolean;
|
||||||
setActiveTool: (tool: string) => void;
|
setActiveTool: (tool: string) => void;
|
||||||
setSelection: (id: string | null) => void;
|
setSelection: (id: string | null) => void;
|
||||||
setZoom: (zoom: number) => void;
|
setZoom: (zoom: number) => void;
|
||||||
setSaveStatus: (status: SaveStatus) => void;
|
setSaveStatus: (status: SaveStatus) => void;
|
||||||
setDefaultTextProps: (props: Partial<TextProps>) => void;
|
setDefaultTextProps: (props: Partial<TextProps>) => void;
|
||||||
setDraftAnnotation: (ann: Annotation | null) => void;
|
setDraftAnnotation: (annotation: Annotation | null) => void;
|
||||||
setIsSignatureModalOpen: (isOpen: boolean) => void;
|
setIsSignatureModalOpen: (isOpen: boolean) => void;
|
||||||
setPendingSignatureProps: (props: any | null) => void;
|
setPendingSignatureProps: (props: SignatureProps | null) => void;
|
||||||
setPendingImageRef: (imgRef: { ref: string, width: number, height: number } | null) => void;
|
setPendingImageRef: (image: PendingImage | null) => void;
|
||||||
activeShapeKind: 'rect' | 'ellipse' | 'line';
|
setActiveShapeKind: (kind: EditorState['activeShapeKind']) => void;
|
||||||
setActiveShapeKind: (kind: 'rect' | 'ellipse' | 'line') => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditorStore = create<EditorState>((set) => ({
|
const MAX_HISTORY = 100;
|
||||||
|
|
||||||
|
function cloneAnnotations(annotations: Annotation[]): Annotation[] {
|
||||||
|
return annotations.map((annotation) => ({
|
||||||
|
...annotation,
|
||||||
|
rect: { ...annotation.rect },
|
||||||
|
props: structuredClone(annotation.props),
|
||||||
|
})) as Annotation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function withHistory(state: EditorState, annotations: Annotation[]): Partial<EditorState> {
|
||||||
|
return {
|
||||||
|
annotations,
|
||||||
|
past: [...state.past, cloneAnnotations(state.annotations)].slice(-MAX_HISTORY),
|
||||||
|
future: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||||
documentId: null,
|
documentId: null,
|
||||||
annotations: [],
|
annotations: [],
|
||||||
activeTool: 'select',
|
past: [],
|
||||||
|
future: [],
|
||||||
selection: null,
|
selection: null,
|
||||||
|
activeTool: 'select',
|
||||||
|
activeShapeKind: 'rect',
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
saveStatus: 'idle',
|
saveStatus: 'idle',
|
||||||
|
annotationUpdatedAt: null,
|
||||||
defaultTextProps: {},
|
defaultTextProps: {},
|
||||||
draftAnnotation: null,
|
draftAnnotation: null,
|
||||||
isSignatureModalOpen: false,
|
isSignatureModalOpen: false,
|
||||||
pendingSignatureProps: null,
|
pendingSignatureProps: null,
|
||||||
pendingImageRef: null,
|
pendingImageRef: null,
|
||||||
activeShapeKind: 'rect',
|
|
||||||
|
|
||||||
setDocumentId: (id) => set({ documentId: id, defaultTextProps: {}, draftAnnotation: null }),
|
setDocumentId: (id) =>
|
||||||
setAnnotations: (annotations) => set({ annotations }),
|
set({
|
||||||
addAnnotation: (annotation) => set((state) => ({
|
documentId: id,
|
||||||
annotations: [...state.annotations, annotation]
|
annotations: [],
|
||||||
})),
|
past: [],
|
||||||
updateAnnotation: (id, updates) => set((state) => ({
|
future: [],
|
||||||
annotations: state.annotations.map(a => a.id === id ? { ...a, ...updates } as Annotation : a)
|
selection: null,
|
||||||
})),
|
saveStatus: id ? 'loading' : 'idle',
|
||||||
deleteAnnotation: (id) => set((state) => ({
|
annotationUpdatedAt: null,
|
||||||
annotations: state.annotations.filter(a => a.id !== id),
|
defaultTextProps: {},
|
||||||
selection: state.selection === id ? null : state.selection
|
draftAnnotation: null,
|
||||||
})),
|
pendingSignatureProps: null,
|
||||||
|
pendingImageRef: null,
|
||||||
|
}),
|
||||||
|
setAnnotations: (annotations) =>
|
||||||
|
set({
|
||||||
|
annotations: cloneAnnotations(annotations),
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
selection: null,
|
||||||
|
draftAnnotation: null,
|
||||||
|
}),
|
||||||
|
setAnnotationUpdatedAt: (updatedAt) => set({ annotationUpdatedAt: updatedAt }),
|
||||||
|
addAnnotation: (annotation) =>
|
||||||
|
set((state) => withHistory(state, cloneAnnotations([...state.annotations, annotation]))),
|
||||||
|
updateAnnotation: (id, updates) =>
|
||||||
|
set((state) => {
|
||||||
|
const current = state.annotations.find((annotation) => annotation.id === id);
|
||||||
|
if (!current) return state;
|
||||||
|
const next = state.annotations.map((annotation) =>
|
||||||
|
annotation.id === id
|
||||||
|
? ({
|
||||||
|
...annotation,
|
||||||
|
...updates,
|
||||||
|
props: updates.props ? { ...annotation.props, ...updates.props } : annotation.props,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as Annotation)
|
||||||
|
: annotation,
|
||||||
|
);
|
||||||
|
return withHistory(state, next);
|
||||||
|
}),
|
||||||
|
deleteAnnotation: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
if (!state.annotations.some((annotation) => annotation.id === id)) return state;
|
||||||
|
return {
|
||||||
|
...withHistory(state, state.annotations.filter((annotation) => annotation.id !== id)),
|
||||||
|
selection: state.selection === id ? null : state.selection,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
undo: () =>
|
||||||
|
set((state) => {
|
||||||
|
const previous = state.past.at(-1);
|
||||||
|
if (!previous) return state;
|
||||||
|
return {
|
||||||
|
annotations: cloneAnnotations(previous),
|
||||||
|
past: state.past.slice(0, -1),
|
||||||
|
future: [cloneAnnotations(state.annotations), ...state.future].slice(0, MAX_HISTORY),
|
||||||
|
selection: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
redo: () =>
|
||||||
|
set((state) => {
|
||||||
|
const next = state.future[0];
|
||||||
|
if (!next) return state;
|
||||||
|
return {
|
||||||
|
annotations: cloneAnnotations(next),
|
||||||
|
past: [...state.past, cloneAnnotations(state.annotations)].slice(-MAX_HISTORY),
|
||||||
|
future: state.future.slice(1),
|
||||||
|
selection: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
canUndo: () => get().past.length > 0,
|
||||||
|
canRedo: () => get().future.length > 0,
|
||||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||||
setSelection: (id) => set({ selection: id }),
|
setSelection: (id) => set({ selection: id }),
|
||||||
setZoom: (zoom) => set({ zoom }),
|
setZoom: (zoom) => set({ zoom: Math.max(0.5, Math.min(3, zoom)) }),
|
||||||
setSaveStatus: (status) => set({ saveStatus: status }),
|
setSaveStatus: (status) => set({ saveStatus: status }),
|
||||||
setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
|
setDefaultTextProps: (props) =>
|
||||||
setDraftAnnotation: (ann) => set({ draftAnnotation: ann }),
|
set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })),
|
||||||
|
setDraftAnnotation: (annotation) => set({ draftAnnotation: annotation }),
|
||||||
setIsSignatureModalOpen: (isOpen) => set({ isSignatureModalOpen: isOpen }),
|
setIsSignatureModalOpen: (isOpen) => set({ isSignatureModalOpen: isOpen }),
|
||||||
setPendingSignatureProps: (props) => set({ pendingSignatureProps: props }),
|
setPendingSignatureProps: (props) => set({ pendingSignatureProps: props }),
|
||||||
setPendingImageRef: (imgRef) => set({ pendingImageRef: imgRef }),
|
setPendingImageRef: (image) => set({ pendingImageRef: image }),
|
||||||
setActiveShapeKind: (kind) => set({ activeShapeKind: kind }),
|
setActiveShapeKind: (kind) => set({ activeShapeKind: kind }),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,44 @@
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState, type ChangeEvent } from 'react';
|
||||||
import { useEditorStore } from '../store';
|
import {
|
||||||
|
Download,
|
||||||
|
History,
|
||||||
|
Highlighter,
|
||||||
|
ImagePlus,
|
||||||
|
Minus,
|
||||||
|
MousePointer2,
|
||||||
|
PenLine,
|
||||||
|
Redo2,
|
||||||
|
Signature,
|
||||||
|
Square,
|
||||||
|
Type,
|
||||||
|
Undo2,
|
||||||
|
ZoomIn,
|
||||||
|
} from 'lucide-react';
|
||||||
import { SignatureModal } from '../tools/SignatureModal';
|
import { SignatureModal } from '../tools/SignatureModal';
|
||||||
import { api } from '../../../lib/api/client';
|
import { api } from '../../../lib/api/client';
|
||||||
|
import { useEditorStore } from '../store';
|
||||||
|
import type { SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types';
|
||||||
|
|
||||||
export function EditorToolbar() {
|
interface EditorToolbarProps {
|
||||||
|
onExport: () => void;
|
||||||
|
onOpenVersions: () => void;
|
||||||
|
isExporting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetUploadResponse {
|
||||||
|
ref: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolButton = 'inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold transition-colors';
|
||||||
|
const inactive = 'text-neutral-600 hover:bg-neutral-100';
|
||||||
|
const active = 'bg-accent-100 text-accent-700';
|
||||||
|
|
||||||
|
export function EditorToolbar({ onExport, onOpenVersions, isExporting }: EditorToolbarProps) {
|
||||||
const {
|
const {
|
||||||
activeTool,
|
activeTool,
|
||||||
setActiveTool,
|
setActiveTool,
|
||||||
|
activeShapeKind,
|
||||||
|
setActiveShapeKind,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
zoom,
|
zoom,
|
||||||
setZoom,
|
setZoom,
|
||||||
|
|
@ -14,144 +46,113 @@ export function EditorToolbar() {
|
||||||
setIsSignatureModalOpen,
|
setIsSignatureModalOpen,
|
||||||
setPendingSignatureProps,
|
setPendingSignatureProps,
|
||||||
setPendingImageRef,
|
setPendingImageRef,
|
||||||
documentId
|
documentId,
|
||||||
|
past,
|
||||||
|
future,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
} = useEditorStore();
|
} = useEditorStore();
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||||
|
|
||||||
const handleZoomIn = () => setZoom(Math.min(zoom + 0.25, 3));
|
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const handleZoomOut = () => setZoom(Math.max(zoom - 0.25, 0.5));
|
const file = event.target.files?.[0];
|
||||||
|
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file || !documentId) return;
|
if (!file || !documentId) return;
|
||||||
|
|
||||||
setIsUploadingImage(true);
|
setIsUploadingImage(true);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
const data = await api.upload<AssetUploadResponse>(`/documents/${documentId}/assets`, formData);
|
||||||
const data = await api.upload<any>(`/documents/${documentId}/assets`, formData);
|
|
||||||
|
|
||||||
// We don't have natural width/height immediately, we'll let the tool figure it out or pass placeholders
|
|
||||||
// A better way is to read image locally to get dimensions, but we can also just let fabric.Image.fromURL do it.
|
|
||||||
// We'll pass 0 for now and let the tool set scale to max-width.
|
|
||||||
setPendingImageRef({ ref: data.ref, width: 0, height: 0 });
|
setPendingImageRef({ ref: data.ref, width: 0, height: 0 });
|
||||||
setActiveTool('image');
|
setActiveTool('image');
|
||||||
} catch (err) {
|
} catch (reason) {
|
||||||
console.error(err);
|
console.error('Failed to upload image', reason);
|
||||||
alert('Failed to upload image');
|
window.alert(reason instanceof Error ? reason.message : 'Failed to upload image.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingImage(false);
|
setIsUploadingImage(false);
|
||||||
// Reset input
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSignatureConfirm = (props: any) => {
|
const handleSignatureConfirm = (props: SignatureDrawProps | SignatureTypeProps) => {
|
||||||
setPendingSignatureProps(props);
|
setPendingSignatureProps(props);
|
||||||
setIsSignatureModalOpen(false);
|
setIsSignatureModalOpen(false);
|
||||||
setActiveTool('signature');
|
setActiveTool('signature');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isActive = (name: string) => activeTool === name ? active : inactive;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 bg-white rounded-lg shadow-xl border border-neutral-200 px-2 py-2 flex items-center gap-2 z-50">
|
<div className="sticky top-3 z-30 mx-auto flex w-fit max-w-[calc(100%-2rem)] flex-wrap items-center justify-center gap-1 rounded-xl border border-neutral-200 bg-white/95 px-2 py-2 shadow-lg backdrop-blur">
|
||||||
<button
|
<button type="button" className={`${toolButton} ${isActive('select')}`} onClick={() => setActiveTool('select')} title="Select and move annotations">
|
||||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
<MousePointer2 className="h-4 w-4" /> Select
|
||||||
activeTool === 'select'
|
</button>
|
||||||
? 'bg-blue-100 text-blue-700'
|
<button type="button" className={`${toolButton} ${isActive('text')}`} onClick={() => setActiveTool('text')} title="Add text">
|
||||||
: 'text-neutral-600 hover:bg-neutral-100'
|
<Type className="h-4 w-4" /> Text
|
||||||
}`}
|
</button>
|
||||||
onClick={() => setActiveTool('select')}
|
<button type="button" className={`${toolButton} ${isActive('draw')}`} onClick={() => setActiveTool('draw')} title="Draw freehand">
|
||||||
|
<PenLine className="h-4 w-4" /> Draw
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`${toolButton} ${isActive('highlight')}`} onClick={() => setActiveTool('highlight')} title="Highlight an area">
|
||||||
|
<Highlighter className="h-4 w-4" /> Highlight
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`${toolButton} ${isActive('shape')}`} onClick={() => setActiveTool('shape')} title="Draw a shape">
|
||||||
|
<Square className="h-4 w-4" /> Shape
|
||||||
|
</button>
|
||||||
|
{activeTool === 'shape' && (
|
||||||
|
<select
|
||||||
|
value={activeShapeKind}
|
||||||
|
onChange={(event) => setActiveShapeKind(event.target.value as typeof activeShapeKind)}
|
||||||
|
className="h-8 rounded-md border border-neutral-200 bg-neutral-50 px-1.5 text-xs font-medium text-neutral-700 outline-none focus:border-accent-500"
|
||||||
|
aria-label="Shape type"
|
||||||
>
|
>
|
||||||
Select
|
<option value="rect">Rectangle</option>
|
||||||
|
<option value="ellipse">Ellipse</option>
|
||||||
|
<option value="line">Line</option>
|
||||||
|
<option value="arrow">Arrow</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button type="button" className={`${toolButton} ${isActive('signature')}`} onClick={() => setIsSignatureModalOpen(true)} title="Add a signature">
|
||||||
|
<Signature className="h-4 w-4" /> Sign
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`${toolButton} ${activeTool === 'image' || isUploadingImage ? active : inactive}`} onClick={() => fileInputRef.current?.click()} disabled={isUploadingImage} title="Add an image">
|
||||||
|
<ImagePlus className="h-4 w-4" /> {isUploadingImage ? 'Uploading…' : 'Image'}
|
||||||
|
</button>
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" className="hidden" onChange={(event) => void handleImageUpload(event)} />
|
||||||
|
|
||||||
|
<span className="mx-1 h-6 w-px bg-neutral-200" />
|
||||||
|
<button type="button" className={`${toolButton} ${past.length ? inactive : 'cursor-not-allowed text-neutral-300'}`} onClick={undo} disabled={!past.length} title="Undo (Ctrl/Cmd+Z)">
|
||||||
|
<Undo2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`${toolButton} ${future.length ? inactive : 'cursor-not-allowed text-neutral-300'}`} onClick={redo} disabled={!future.length} title="Redo (Ctrl/Cmd+Shift+Z)">
|
||||||
|
<Redo2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<span className="mx-1 h-6 w-px bg-neutral-200" />
|
||||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
<button type="button" className={`${toolButton} ${inactive}`} onClick={() => setZoom(zoom - 0.25)} disabled={zoom <= 0.5} title="Zoom out">
|
||||||
activeTool === 'text'
|
<Minus className="h-4 w-4" />
|
||||||
? 'bg-blue-100 text-blue-700'
|
</button>
|
||||||
: 'text-neutral-600 hover:bg-neutral-100'
|
<span className="min-w-12 text-center text-xs font-semibold text-neutral-600">{Math.round(zoom * 100)}%</span>
|
||||||
}`}
|
<button type="button" className={`${toolButton} ${inactive}`} onClick={() => setZoom(zoom + 0.25)} disabled={zoom >= 3} title="Zoom in">
|
||||||
onClick={() => setActiveTool('text')}
|
<ZoomIn className="h-4 w-4" />
|
||||||
>
|
|
||||||
Add Text
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<span className="mx-1 h-6 w-px bg-neutral-200" />
|
||||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
<button type="button" className={`${toolButton} ${inactive}`} onClick={onOpenVersions} title="Open checkpoints">
|
||||||
activeTool === 'draw'
|
<History className="h-4 w-4" /> History
|
||||||
? 'bg-blue-100 text-blue-700'
|
</button>
|
||||||
: 'text-neutral-600 hover:bg-neutral-100'
|
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md bg-accent-600 px-3 text-xs font-bold text-white transition-colors hover:bg-accent-700 disabled:cursor-wait disabled:opacity-60" onClick={onExport} disabled={isExporting} title="Export flattened PDF">
|
||||||
}`}
|
<Download className="h-4 w-4" /> {isExporting ? 'Exporting…' : 'Export'}
|
||||||
onClick={() => setActiveTool('draw')}
|
|
||||||
>
|
|
||||||
Draw
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<span className="ml-1 min-w-16 text-center text-[11px] font-semibold text-neutral-500">
|
||||||
|
{saveStatus === 'loading' && 'Loading…'}
|
||||||
<button
|
{saveStatus === 'saving' && 'Saving…'}
|
||||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
activeTool === 'signature'
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: 'text-neutral-600 hover:bg-neutral-100'
|
|
||||||
}`}
|
|
||||||
onClick={() => setIsSignatureModalOpen(true)}
|
|
||||||
>
|
|
||||||
Signature
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
activeTool === 'image' || isUploadingImage
|
|
||||||
? 'bg-blue-100 text-blue-700'
|
|
||||||
: 'text-neutral-600 hover:bg-neutral-100'
|
|
||||||
}`}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={isUploadingImage}
|
|
||||||
>
|
|
||||||
{isUploadingImage ? 'Uploading...' : 'Image'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={fileInputRef}
|
|
||||||
accept="image/*, image/webp"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleImageUpload}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-neutral-200 mx-2" />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
|
|
||||||
>
|
|
||||||
-
|
|
||||||
</button>
|
|
||||||
<span className="text-sm font-medium text-neutral-600 w-12 text-center">
|
|
||||||
{Math.round(zoom * 100)}%
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
className="px-2 py-1 text-sm font-medium text-neutral-600 hover:bg-neutral-100 rounded"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-px h-6 bg-neutral-200 mx-2" />
|
|
||||||
|
|
||||||
<div className="text-xs font-medium text-neutral-400 min-w-16 text-center">
|
|
||||||
{saveStatus === 'saving' && 'Saving...'}
|
|
||||||
{saveStatus === 'saved' && 'Saved'}
|
{saveStatus === 'saved' && 'Saved'}
|
||||||
{saveStatus === 'error' && <span className="text-red-500">Error saving</span>}
|
{saveStatus === 'error' && <span className="text-red-600">Save failed</span>}
|
||||||
{saveStatus === 'idle' && ''}
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSignatureModalOpen && (
|
{isSignatureModalOpen && (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import * as fabric from 'fabric';
|
||||||
|
import type { Canvas } from 'fabric';
|
||||||
import { useEditorStore } from '../store';
|
import { useEditorStore } from '../store';
|
||||||
import type { TextAnnotation, TextProps } from '../../../lib/annotations/types';
|
import type { TextAnnotation, TextProps } from '../../../lib/annotations/types';
|
||||||
import type { ViewportParams } from '../../../lib/coords';
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
|
|
@ -17,6 +19,7 @@ import {
|
||||||
interface TextFormatToolbarProps {
|
interface TextFormatToolbarProps {
|
||||||
annotationId: string;
|
annotationId: string;
|
||||||
viewportParams: ViewportParams;
|
viewportParams: ViewportParams;
|
||||||
|
canvas: Canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New'];
|
const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New'];
|
||||||
|
|
@ -24,7 +27,7 @@ const SIZES = [6, 7, 8, 10, 12, 14, 16, 18, 24, 36, 48, 72];
|
||||||
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
|
const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff'];
|
||||||
const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000'];
|
const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000'];
|
||||||
|
|
||||||
export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatToolbarProps) {
|
export function TextFormatToolbar({ annotationId, viewportParams, canvas }: TextFormatToolbarProps) {
|
||||||
const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore();
|
const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore();
|
||||||
|
|
||||||
const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null);
|
const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null);
|
||||||
|
|
@ -51,11 +54,9 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
const top = pt.y - 48; // 48px above
|
const top = pt.y - 48; // 48px above
|
||||||
const left = pt.x;
|
const left = pt.x;
|
||||||
|
|
||||||
const applyStyle = (styleName: string, value: any, globalPropName: keyof TextProps, globalValue?: any) => {
|
const applyStyle = (styleName: string, value: unknown, globalPropName: keyof TextProps, globalValue?: unknown) => {
|
||||||
const canvas = (window as any).__fabricCanvas as any;
|
const activeObj = canvas.getActiveObject() as (fabric.Textbox & { id?: string; customHeight?: number }) | undefined;
|
||||||
|
if (activeObj) {
|
||||||
if (canvas) {
|
|
||||||
const activeObj = canvas.getActiveObject();
|
|
||||||
if (activeObj && activeObj.id === annotationId) {
|
if (activeObj && activeObj.id === annotationId) {
|
||||||
const isStructural = styleName === 'fontSize' || styleName === 'fontFamily';
|
const isStructural = styleName === 'fontSize' || styleName === 'fontFamily';
|
||||||
|
|
||||||
|
|
@ -81,7 +82,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
activeObj.styles = {};
|
activeObj.styles = {};
|
||||||
if (activeObj.hiddenTextarea) {
|
if (activeObj.hiddenTextarea) {
|
||||||
if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`;
|
if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`;
|
||||||
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = value;
|
if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -100,7 +101,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
for (const line in activeObj.styles) {
|
for (const line in activeObj.styles) {
|
||||||
for (const char in activeObj.styles[line]) {
|
for (const char in activeObj.styles[line]) {
|
||||||
if (activeObj.styles[line][char]) {
|
if (activeObj.styles[line][char]) {
|
||||||
delete activeObj.styles[line][char][styleName];
|
delete (activeObj.styles[line][char] as Record<string, unknown>)[styleName];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,8 +111,8 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
|
|
||||||
// Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw!
|
// Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw!
|
||||||
activeObj.dirty = true;
|
activeObj.dirty = true;
|
||||||
if ((activeObj as any)._forceClearCache !== undefined) {
|
if ('_forceClearCache' in activeObj) {
|
||||||
(activeObj as any)._forceClearCache = true;
|
(activeObj as typeof activeObj & { _forceClearCache?: boolean })._forceClearCache = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove manual height constraint so the box can grow with the new font size
|
// Remove manual height constraint so the box can grow with the new font size
|
||||||
|
|
@ -123,7 +124,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalGlobalValue = globalValue !== undefined ? globalValue : value;
|
const finalGlobalValue = globalValue !== undefined ? globalValue : value;
|
||||||
const newProps = { [globalPropName]: finalGlobalValue } as any;
|
const newProps = { [globalPropName]: finalGlobalValue } as Partial<TextProps>;
|
||||||
setDefaultTextProps(newProps);
|
setDefaultTextProps(newProps);
|
||||||
|
|
||||||
// Always update store so the toolbar displays the new value
|
// Always update store so the toolbar displays the new value
|
||||||
|
|
@ -154,13 +155,10 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (isDraft) {
|
if (isDraft) {
|
||||||
useEditorStore.getState().setDraftAnnotation(null);
|
useEditorStore.getState().setDraftAnnotation(null);
|
||||||
const canvas = (window as any).__fabricCanvas as any;
|
|
||||||
if (canvas) {
|
|
||||||
const activeObj = canvas.getActiveObject();
|
const activeObj = canvas.getActiveObject();
|
||||||
if (activeObj && activeObj.id === annotationId) {
|
if (activeObj && (activeObj as fabric.FabricObject & { id?: string }).id === annotationId) {
|
||||||
canvas.remove(activeObj);
|
canvas.remove(activeObj);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
deleteAnnotation(annotationId);
|
deleteAnnotation(annotationId);
|
||||||
}
|
}
|
||||||
|
|
@ -220,7 +218,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
className={`w-full text-center px-3 py-1 text-xs hover:bg-blue-50 ${props.fontSize === s ? 'bg-blue-50 text-blue-600 font-medium' : ''}`}
|
className={`w-full text-center px-3 py-1 text-xs hover:bg-blue-50 ${props.fontSize === s ? 'bg-blue-50 text-blue-600 font-medium' : ''}`}
|
||||||
onClick={() => { applyStyle('fontSize', s * viewportParams.scale * (viewportParams.dpr || 1), 'fontSize', s); setActiveDropdown(null); }}
|
onClick={() => { applyStyle('fontSize', s * viewportParams.scale, 'fontSize', s); setActiveDropdown(null); }}
|
||||||
>
|
>
|
||||||
{s}
|
{s}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ import type { Annotation, DrawAnnotation, DrawProps } from '../../../lib/annotat
|
||||||
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||||
import type { ViewportParams } from '../../../lib/coords';
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
|
|
||||||
|
type FabricAnnotationObject = fabric.FabricObject & {
|
||||||
|
id?: string;
|
||||||
|
annotationType?: string;
|
||||||
|
annotationProps?: DrawProps;
|
||||||
|
};
|
||||||
|
|
||||||
export const DrawTool: ToolHandler = {
|
export const DrawTool: ToolHandler = {
|
||||||
name: 'draw',
|
name: 'draw',
|
||||||
|
|
||||||
|
|
@ -21,7 +27,7 @@ export const DrawTool: ToolHandler = {
|
||||||
canvas.isDrawingMode = false;
|
canvas.isDrawingMode = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
onPathCreated: (e: any, _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
onPathCreated: (e: fabric.CanvasEvents['path:created'], _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||||
const pathObj = e.path as fabric.Path;
|
const pathObj = e.path as fabric.Path;
|
||||||
|
|
||||||
pathObj.set({
|
pathObj.set({
|
||||||
|
|
@ -33,27 +39,16 @@ export const DrawTool: ToolHandler = {
|
||||||
padding: 5,
|
padding: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
(pathObj as any).id = uuidv4();
|
const annotatedPath = pathObj as FabricAnnotationObject;
|
||||||
(pathObj as any).annotationType = 'draw';
|
annotatedPath.id = uuidv4();
|
||||||
|
annotatedPath.annotationType = 'draw';
|
||||||
|
|
||||||
// The path data is stored in `pathObj.path` array of commands, e.g. [['M', x, y], ['Q', cx, cy, x, y]]
|
// Keep the SVG path for faithful browser re-rendering and also store a
|
||||||
// We can serialize it via pathObj.toObject().path or complexPathToString(pathObj.path)
|
// canonical point list for the server export renderer.
|
||||||
// fabric 7 has toObject().path or you can just rely on the object's serialization
|
|
||||||
|
|
||||||
// Convert paths to string representation
|
|
||||||
const pathStr = Array.isArray(pathObj.path)
|
const pathStr = Array.isArray(pathObj.path)
|
||||||
? pathObj.path.map(cmd => cmd.join(' ')).join(' ')
|
? pathObj.path.map(cmd => cmd.join(' ')).join(' ')
|
||||||
: pathObj.path;
|
: pathObj.path;
|
||||||
|
|
||||||
const props: DrawProps = {
|
|
||||||
paths: [], // we ignore points array for now and use svgPath
|
|
||||||
svgPath: pathStr as string,
|
|
||||||
strokeColor: pathObj.stroke as string,
|
|
||||||
strokeWidth: pathObj.strokeWidth,
|
|
||||||
opacity: pathObj.opacity
|
|
||||||
};
|
|
||||||
(pathObj as any).annotationProps = props;
|
|
||||||
|
|
||||||
const bounds = pathObj.getBoundingRect();
|
const bounds = pathObj.getBoundingRect();
|
||||||
const pdfRect = screenRectToPdf({
|
const pdfRect = screenRectToPdf({
|
||||||
x: bounds.left,
|
x: bounds.left,
|
||||||
|
|
@ -61,9 +56,36 @@ export const DrawTool: ToolHandler = {
|
||||||
width: bounds.width,
|
width: bounds.width,
|
||||||
height: bounds.height
|
height: bounds.height
|
||||||
}, vp);
|
}, vp);
|
||||||
|
const rawPoints = Array.isArray(pathObj.path)
|
||||||
|
? pathObj.path.flatMap((command) => {
|
||||||
|
const values = command.slice(1).filter((value): value is number => typeof value === 'number');
|
||||||
|
const points: [number, number][] = [];
|
||||||
|
for (let index = 0; index + 1 < values.length; index += 2) {
|
||||||
|
points.push([values[index], values[index + 1]]);
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const minX = rawPoints.length ? Math.min(...rawPoints.map(([x]) => x)) : 0;
|
||||||
|
const maxX = rawPoints.length ? Math.max(...rawPoints.map(([x]) => x)) : 1;
|
||||||
|
const minY = rawPoints.length ? Math.min(...rawPoints.map(([, y]) => y)) : 0;
|
||||||
|
const maxY = rawPoints.length ? Math.max(...rawPoints.map(([, y]) => y)) : 1;
|
||||||
|
const sourceWidth = Math.max(maxX - minX, 1e-6);
|
||||||
|
const sourceHeight = Math.max(maxY - minY, 1e-6);
|
||||||
|
const props: DrawProps = {
|
||||||
|
paths: rawPoints.map(([x, y]) => [
|
||||||
|
((x - minX) / sourceWidth) * pdfRect.width,
|
||||||
|
((y - minY) / sourceHeight) * pdfRect.height,
|
||||||
|
]),
|
||||||
|
svgPath: pathStr as string,
|
||||||
|
strokeColor: pathObj.stroke as string,
|
||||||
|
strokeWidth: pathObj.strokeWidth,
|
||||||
|
opacity: pathObj.opacity,
|
||||||
|
};
|
||||||
|
annotatedPath.annotationProps = props;
|
||||||
|
|
||||||
const annotation: DrawAnnotation = {
|
const annotation: DrawAnnotation = {
|
||||||
id: (pathObj as any).id,
|
id: annotatedPath.id as string,
|
||||||
page: pageNumber,
|
page: pageNumber,
|
||||||
type: 'draw',
|
type: 'draw',
|
||||||
rect: pdfRect,
|
rect: pdfRect,
|
||||||
|
|
@ -81,7 +103,7 @@ export const DrawTool: ToolHandler = {
|
||||||
},
|
},
|
||||||
|
|
||||||
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||||
if (annotation.type !== 'draw') return null;
|
if (annotation.type !== 'draw') return;
|
||||||
const drawAnn = annotation as DrawAnnotation;
|
const drawAnn = annotation as DrawAnnotation;
|
||||||
const props = drawAnn.props as DrawProps;
|
const props = drawAnn.props as DrawProps;
|
||||||
const screenRect = pdfRectToScreen(drawAnn.rect, vp);
|
const screenRect = pdfRectToScreen(drawAnn.rect, vp);
|
||||||
|
|
@ -103,6 +125,8 @@ export const DrawTool: ToolHandler = {
|
||||||
pathObj.set({
|
pathObj.set({
|
||||||
left: screenRect.x,
|
left: screenRect.x,
|
||||||
top: screenRect.y,
|
top: screenRect.y,
|
||||||
|
originX: 'left',
|
||||||
|
originY: 'top',
|
||||||
scaleX: screenRect.width / pathObj.width!,
|
scaleX: screenRect.width / pathObj.width!,
|
||||||
scaleY: screenRect.height / pathObj.height!,
|
scaleY: screenRect.height / pathObj.height!,
|
||||||
});
|
});
|
||||||
|
|
@ -111,6 +135,8 @@ export const DrawTool: ToolHandler = {
|
||||||
pathObj = new fabric.Path('M 0 0', {
|
pathObj = new fabric.Path('M 0 0', {
|
||||||
left: screenRect.x,
|
left: screenRect.x,
|
||||||
top: screenRect.y,
|
top: screenRect.y,
|
||||||
|
originX: 'left',
|
||||||
|
originY: 'top',
|
||||||
width: screenRect.width,
|
width: screenRect.width,
|
||||||
height: screenRect.height,
|
height: screenRect.height,
|
||||||
stroke: props.strokeColor || '#000000',
|
stroke: props.strokeColor || '#000000',
|
||||||
|
|
@ -118,17 +144,18 @@ export const DrawTool: ToolHandler = {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const annotatedPath = pathObj as FabricAnnotationObject;
|
||||||
|
annotatedPath.id = drawAnn.id;
|
||||||
|
annotatedPath.annotationType = 'draw';
|
||||||
|
annotatedPath.annotationProps = drawAnn.props;
|
||||||
pathObj.set({
|
pathObj.set({
|
||||||
id: drawAnn.id,
|
|
||||||
annotationType: 'draw',
|
|
||||||
annotationProps: drawAnn.props,
|
|
||||||
transparentCorners: false,
|
transparentCorners: false,
|
||||||
cornerColor: '#3b82f6',
|
cornerColor: '#3b82f6',
|
||||||
cornerStrokeColor: '#3b82f6',
|
cornerStrokeColor: '#3b82f6',
|
||||||
borderColor: '#3b82f6',
|
borderColor: '#3b82f6',
|
||||||
cornerSize: 8,
|
cornerSize: 8,
|
||||||
padding: 5,
|
padding: 5,
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
canvas.add(pathObj);
|
canvas.add(pathObj);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
120
frontend/src/features/editor/tools/HighlightTool.ts
Normal file
120
frontend/src/features/editor/tools/HighlightTool.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import * as fabric from 'fabric';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { useEditorStore } from '../store';
|
||||||
|
import type { ToolHandler } from '../../../lib/annotations/registry';
|
||||||
|
import type { Annotation, HighlightAnnotation, HighlightProps } from '../../../lib/annotations/types';
|
||||||
|
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
|
||||||
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
|
|
||||||
|
type PointerEvent = fabric.TPointerEventInfo;
|
||||||
|
type Draft = { object: fabric.Rect; start: { x: number; y: number } };
|
||||||
|
const drafts = new WeakMap<fabric.Canvas, Draft>();
|
||||||
|
|
||||||
|
function point(event: PointerEvent, canvas: fabric.Canvas) {
|
||||||
|
return event.scenePoint ?? canvas.getScenePoint(event.e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function configure(object: fabric.Rect) {
|
||||||
|
object.set({
|
||||||
|
originX: 'left',
|
||||||
|
originY: 'top',
|
||||||
|
fill: '#facc15',
|
||||||
|
opacity: 0.35,
|
||||||
|
stroke: undefined,
|
||||||
|
transparentCorners: false,
|
||||||
|
cornerColor: '#3b82f6',
|
||||||
|
cornerStrokeColor: '#3b82f6',
|
||||||
|
borderColor: '#3b82f6',
|
||||||
|
cornerSize: 8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HighlightTool: ToolHandler = {
|
||||||
|
name: 'highlight',
|
||||||
|
onActivate: (canvas) => {
|
||||||
|
canvas.defaultCursor = 'crosshair';
|
||||||
|
canvas.selection = false;
|
||||||
|
},
|
||||||
|
onDeactivate: (canvas) => {
|
||||||
|
const draft = drafts.get(canvas);
|
||||||
|
if (draft) canvas.remove(draft.object);
|
||||||
|
drafts.delete(canvas);
|
||||||
|
canvas.defaultCursor = 'default';
|
||||||
|
},
|
||||||
|
onPointerDown: (event: PointerEvent, canvas) => {
|
||||||
|
if (event.target) return;
|
||||||
|
const start = point(event, canvas);
|
||||||
|
const object = new fabric.Rect({ left: start.x, top: start.y, width: 0, height: 0 });
|
||||||
|
configure(object);
|
||||||
|
drafts.set(canvas, { object, start });
|
||||||
|
canvas.add(object);
|
||||||
|
},
|
||||||
|
onPointerMove: (event: PointerEvent, canvas) => {
|
||||||
|
const draft = drafts.get(canvas);
|
||||||
|
if (!draft) return;
|
||||||
|
const current = point(event, canvas);
|
||||||
|
draft.object.set({
|
||||||
|
left: Math.min(draft.start.x, current.x),
|
||||||
|
top: Math.min(draft.start.y, current.y),
|
||||||
|
width: Math.abs(current.x - draft.start.x),
|
||||||
|
height: Math.abs(current.y - draft.start.y),
|
||||||
|
});
|
||||||
|
draft.object.setCoords();
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
},
|
||||||
|
onPointerUp: (event: PointerEvent, canvas, vp: ViewportParams, pageNumber) => {
|
||||||
|
const draft = drafts.get(canvas);
|
||||||
|
if (!draft) return;
|
||||||
|
const current = point(event, canvas);
|
||||||
|
draft.object.set({
|
||||||
|
left: Math.min(draft.start.x, current.x),
|
||||||
|
top: Math.min(draft.start.y, current.y),
|
||||||
|
width: Math.abs(current.x - draft.start.x),
|
||||||
|
height: Math.abs(current.y - draft.start.y),
|
||||||
|
});
|
||||||
|
draft.object.setCoords();
|
||||||
|
const bounds = draft.object.getBoundingRect();
|
||||||
|
drafts.delete(canvas);
|
||||||
|
if (bounds.width < 2 || bounds.height < 2) {
|
||||||
|
canvas.remove(draft.object);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = uuidv4();
|
||||||
|
draft.object.set({ id });
|
||||||
|
const props: HighlightProps = { color: '#facc15', opacity: 0.35 };
|
||||||
|
useEditorStore.getState().addAnnotation({
|
||||||
|
id,
|
||||||
|
page: pageNumber,
|
||||||
|
type: 'highlight',
|
||||||
|
rect: screenRectToPdf(
|
||||||
|
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
|
||||||
|
vp,
|
||||||
|
),
|
||||||
|
rotation: 0,
|
||||||
|
z: 0,
|
||||||
|
props,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as HighlightAnnotation);
|
||||||
|
canvas.setActiveObject(draft.object);
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
},
|
||||||
|
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||||
|
if (annotation.type !== 'highlight') return;
|
||||||
|
const highlight = annotation as HighlightAnnotation;
|
||||||
|
const rect = pdfRectToScreen(highlight.rect, vp);
|
||||||
|
const object = new fabric.Rect({
|
||||||
|
left: rect.x,
|
||||||
|
top: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
});
|
||||||
|
configure(object);
|
||||||
|
object.set({
|
||||||
|
id: highlight.id,
|
||||||
|
fill: highlight.props.color,
|
||||||
|
opacity: highlight.props.opacity,
|
||||||
|
});
|
||||||
|
canvas.add(object);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -6,6 +6,12 @@ import type { Annotation, ImageAnnotation, ImageProps } from '../../../lib/annot
|
||||||
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||||
import type { ViewportParams } from '../../../lib/coords';
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
|
|
||||||
|
type FabricAnnotationObject = fabric.FabricObject & {
|
||||||
|
id?: string;
|
||||||
|
annotationType?: string;
|
||||||
|
annotationProps?: ImageProps;
|
||||||
|
};
|
||||||
|
|
||||||
export const ImageTool: ToolHandler = {
|
export const ImageTool: ToolHandler = {
|
||||||
name: 'image',
|
name: 'image',
|
||||||
|
|
||||||
|
|
@ -18,7 +24,7 @@ export const ImageTool: ToolHandler = {
|
||||||
canvas.defaultCursor = 'default';
|
canvas.defaultCursor = 'default';
|
||||||
},
|
},
|
||||||
|
|
||||||
onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||||
if (e.target) return;
|
if (e.target) return;
|
||||||
|
|
||||||
const storeState = useEditorStore.getState();
|
const storeState = useEditorStore.getState();
|
||||||
|
|
@ -45,8 +51,8 @@ export const ImageTool: ToolHandler = {
|
||||||
img.set({
|
img.set({
|
||||||
left: pointer.x,
|
left: pointer.x,
|
||||||
top: pointer.y,
|
top: pointer.y,
|
||||||
originX: 'center',
|
originX: 'left',
|
||||||
originY: 'center',
|
originY: 'top',
|
||||||
transparentCorners: false,
|
transparentCorners: false,
|
||||||
cornerColor: '#3b82f6',
|
cornerColor: '#3b82f6',
|
||||||
cornerStrokeColor: '#3b82f6',
|
cornerStrokeColor: '#3b82f6',
|
||||||
|
|
@ -55,15 +61,16 @@ export const ImageTool: ToolHandler = {
|
||||||
padding: 5,
|
padding: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
(img as any).id = uuidv4();
|
const annotatedImage = img as FabricAnnotationObject;
|
||||||
(img as any).annotationType = 'image';
|
annotatedImage.id = uuidv4();
|
||||||
|
annotatedImage.annotationType = 'image';
|
||||||
|
|
||||||
const props: ImageProps = {
|
const props: ImageProps = {
|
||||||
ref: pendingImage.ref,
|
ref: pendingImage.ref,
|
||||||
naturalWidth: pendingImage.width,
|
naturalWidth: pendingImage.width,
|
||||||
naturalHeight: pendingImage.height
|
naturalHeight: pendingImage.height
|
||||||
};
|
};
|
||||||
(img as any).annotationProps = props;
|
annotatedImage.annotationProps = props;
|
||||||
|
|
||||||
canvas.add(img);
|
canvas.add(img);
|
||||||
canvas.setActiveObject(img);
|
canvas.setActiveObject(img);
|
||||||
|
|
@ -78,7 +85,7 @@ export const ImageTool: ToolHandler = {
|
||||||
}, vp);
|
}, vp);
|
||||||
|
|
||||||
const annotation: ImageAnnotation = {
|
const annotation: ImageAnnotation = {
|
||||||
id: (img as any).id,
|
id: annotatedImage.id as string,
|
||||||
page: pageNumber,
|
page: pageNumber,
|
||||||
type: 'image',
|
type: 'image',
|
||||||
rect: pdfRect,
|
rect: pdfRect,
|
||||||
|
|
@ -97,7 +104,7 @@ export const ImageTool: ToolHandler = {
|
||||||
},
|
},
|
||||||
|
|
||||||
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||||
if (annotation.type !== 'image') return null;
|
if (annotation.type !== 'image') return;
|
||||||
const imgAnn = annotation as ImageAnnotation;
|
const imgAnn = annotation as ImageAnnotation;
|
||||||
const screenRect = pdfRectToScreen(imgAnn.rect, vp);
|
const screenRect = pdfRectToScreen(imgAnn.rect, vp);
|
||||||
|
|
||||||
|
|
@ -107,24 +114,27 @@ export const ImageTool: ToolHandler = {
|
||||||
img = await fabric.Image.fromURL(url);
|
img = await fabric.Image.fromURL(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load asset", e);
|
console.error("Failed to load asset", e);
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const annotatedImage = img as FabricAnnotationObject;
|
||||||
|
annotatedImage.id = imgAnn.id;
|
||||||
|
annotatedImage.annotationType = 'image';
|
||||||
|
annotatedImage.annotationProps = imgAnn.props;
|
||||||
img.set({
|
img.set({
|
||||||
left: screenRect.x,
|
left: screenRect.x,
|
||||||
top: screenRect.y,
|
top: screenRect.y,
|
||||||
|
originX: 'left',
|
||||||
|
originY: 'top',
|
||||||
scaleX: screenRect.width / img.width!,
|
scaleX: screenRect.width / img.width!,
|
||||||
scaleY: screenRect.height / img.height!,
|
scaleY: screenRect.height / img.height!,
|
||||||
id: imgAnn.id,
|
|
||||||
annotationType: 'image',
|
|
||||||
annotationProps: imgAnn.props,
|
|
||||||
transparentCorners: false,
|
transparentCorners: false,
|
||||||
cornerColor: '#3b82f6',
|
cornerColor: '#3b82f6',
|
||||||
cornerStrokeColor: '#3b82f6',
|
cornerStrokeColor: '#3b82f6',
|
||||||
borderColor: '#3b82f6',
|
borderColor: '#3b82f6',
|
||||||
cornerSize: 8,
|
cornerSize: 8,
|
||||||
padding: 5,
|
padding: 5,
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
canvas.add(img);
|
canvas.add(img);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
210
frontend/src/features/editor/tools/ShapeTool.ts
Normal file
210
frontend/src/features/editor/tools/ShapeTool.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import * as fabric from 'fabric';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { useEditorStore } from '../store';
|
||||||
|
import type { ToolHandler } from '../../../lib/annotations/registry';
|
||||||
|
import type { Annotation, ShapeAnnotation, ShapeProps } from '../../../lib/annotations/types';
|
||||||
|
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
|
||||||
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
|
|
||||||
|
type PointerEvent = fabric.TPointerEventInfo;
|
||||||
|
type Draft = { object: fabric.FabricObject; start: { x: number; y: number }; kind: ShapeProps['kind'] };
|
||||||
|
const drafts = new WeakMap<fabric.Canvas, Draft>();
|
||||||
|
|
||||||
|
function pointer(event: PointerEvent, canvas: fabric.Canvas) {
|
||||||
|
return event.scenePoint ?? canvas.getScenePoint(event.e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function style(object: fabric.FabricObject) {
|
||||||
|
object.set({
|
||||||
|
originX: 'left',
|
||||||
|
originY: 'top',
|
||||||
|
transparentCorners: false,
|
||||||
|
cornerColor: '#3b82f6',
|
||||||
|
cornerStrokeColor: '#3b82f6',
|
||||||
|
borderColor: '#3b82f6',
|
||||||
|
cornerSize: 8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrowGeometry(start: { x: number; y: number }, end: { x: number; y: number }) {
|
||||||
|
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
||||||
|
const length = Math.min(12, Math.max(5, Math.hypot(end.x - start.x, end.y - start.y) * 0.2));
|
||||||
|
const left = {
|
||||||
|
x: end.x - length * Math.cos(angle - Math.PI / 6),
|
||||||
|
y: end.y - length * Math.sin(angle - Math.PI / 6),
|
||||||
|
};
|
||||||
|
const right = {
|
||||||
|
x: end.x - length * Math.cos(angle + Math.PI / 6),
|
||||||
|
y: end.y - length * Math.sin(angle + Math.PI / 6),
|
||||||
|
};
|
||||||
|
const minX = Math.min(start.x, end.x, left.x, right.x);
|
||||||
|
const minY = Math.min(start.y, end.y, left.y, right.y);
|
||||||
|
const point = (value: { x: number; y: number }) => `${value.x - minX} ${value.y - minY}`;
|
||||||
|
return {
|
||||||
|
left: minX,
|
||||||
|
top: minY,
|
||||||
|
path: `M ${point(start)} L ${point(end)} M ${point(end)} L ${point(left)} M ${point(end)} L ${point(right)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArrow(start: { x: number; y: number }, end: { x: number; y: number }) {
|
||||||
|
const geometry = arrowGeometry(start, end);
|
||||||
|
return new fabric.Path(geometry.path, {
|
||||||
|
left: geometry.left,
|
||||||
|
top: geometry.top,
|
||||||
|
fill: 'transparent',
|
||||||
|
stroke: '#111827',
|
||||||
|
strokeWidth: 2,
|
||||||
|
originX: 'left',
|
||||||
|
originY: 'top',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createObject(kind: ShapeProps['kind'], start: { x: number; y: number }) {
|
||||||
|
const common = { left: start.x, top: start.y, fill: 'transparent', stroke: '#111827', strokeWidth: 2 };
|
||||||
|
if (kind === 'ellipse') return new fabric.Ellipse({ ...common, rx: 0, ry: 0 });
|
||||||
|
if (kind === 'arrow') return createArrow(start, start);
|
||||||
|
if (kind === 'line') return new fabric.Line([0, 0, 0, 0], common);
|
||||||
|
return new fabric.Rect({ ...common, width: 0, height: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateObject(draft: Draft, current: { x: number; y: number }, canvas: fabric.Canvas) {
|
||||||
|
const { object, start, kind } = draft;
|
||||||
|
if (kind === 'arrow') {
|
||||||
|
const replacement = createArrow(start, current);
|
||||||
|
style(replacement);
|
||||||
|
canvas.remove(object);
|
||||||
|
canvas.add(replacement);
|
||||||
|
draft.object = replacement;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const width = current.x - start.x;
|
||||||
|
const height = current.y - start.y;
|
||||||
|
if (kind === 'line') {
|
||||||
|
object.set({ x2: width, y2: height });
|
||||||
|
} else {
|
||||||
|
object.set({
|
||||||
|
left: Math.min(start.x, current.x),
|
||||||
|
top: Math.min(start.y, current.y),
|
||||||
|
...(kind === 'ellipse'
|
||||||
|
? { rx: Math.abs(width) / 2, ry: Math.abs(height) / 2 }
|
||||||
|
: { width: Math.abs(width), height: Math.abs(height) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
object.setCoords();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShapeTool: ToolHandler = {
|
||||||
|
name: 'shape',
|
||||||
|
onActivate: (canvas) => {
|
||||||
|
canvas.defaultCursor = 'crosshair';
|
||||||
|
canvas.selection = false;
|
||||||
|
},
|
||||||
|
onDeactivate: (canvas) => {
|
||||||
|
const draft = drafts.get(canvas);
|
||||||
|
if (draft) canvas.remove(draft.object);
|
||||||
|
drafts.delete(canvas);
|
||||||
|
canvas.defaultCursor = 'default';
|
||||||
|
},
|
||||||
|
onPointerDown: (event: PointerEvent, canvas) => {
|
||||||
|
if (event.target) return;
|
||||||
|
const start = pointer(event, canvas);
|
||||||
|
const kind = useEditorStore.getState().activeShapeKind;
|
||||||
|
const object = createObject(kind, start);
|
||||||
|
style(object);
|
||||||
|
drafts.set(canvas, { object, start, kind });
|
||||||
|
canvas.add(object);
|
||||||
|
},
|
||||||
|
onPointerMove: (event: PointerEvent, canvas) => {
|
||||||
|
const draft = drafts.get(canvas);
|
||||||
|
if (!draft) return;
|
||||||
|
updateObject(draft, pointer(event, canvas), canvas);
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
},
|
||||||
|
onPointerUp: (event: PointerEvent, canvas, vp: ViewportParams, pageNumber) => {
|
||||||
|
const draft = drafts.get(canvas);
|
||||||
|
if (!draft) return;
|
||||||
|
updateObject(draft, pointer(event, canvas), canvas);
|
||||||
|
const bounds = draft.object.getBoundingRect();
|
||||||
|
drafts.delete(canvas);
|
||||||
|
if (bounds.width < 2 && bounds.height < 2) {
|
||||||
|
canvas.remove(draft.object);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = uuidv4();
|
||||||
|
draft.object.set({ id });
|
||||||
|
const props: ShapeProps = {
|
||||||
|
kind: draft.kind,
|
||||||
|
strokeColor: '#111827',
|
||||||
|
fillColor: 'transparent',
|
||||||
|
strokeWidth: 2,
|
||||||
|
};
|
||||||
|
if (draft.kind === 'line' || draft.kind === 'arrow') {
|
||||||
|
const width = Math.max(bounds.width, 1);
|
||||||
|
const height = Math.max(bounds.height, 1);
|
||||||
|
const current = pointer(event, canvas);
|
||||||
|
props.start = [
|
||||||
|
(draft.start.x - bounds.left) / width,
|
||||||
|
(draft.start.y - bounds.top) / height,
|
||||||
|
];
|
||||||
|
props.end = [
|
||||||
|
(current.x - bounds.left) / width,
|
||||||
|
(current.y - bounds.top) / height,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
useEditorStore.getState().addAnnotation({
|
||||||
|
id,
|
||||||
|
page: pageNumber,
|
||||||
|
type: 'shape',
|
||||||
|
rect: screenRectToPdf(
|
||||||
|
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
|
||||||
|
vp,
|
||||||
|
),
|
||||||
|
rotation: 0,
|
||||||
|
z: 0,
|
||||||
|
props,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as ShapeAnnotation);
|
||||||
|
canvas.setActiveObject(draft.object);
|
||||||
|
canvas.requestRenderAll();
|
||||||
|
},
|
||||||
|
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||||
|
if (annotation.type !== 'shape') return;
|
||||||
|
const shape = annotation as ShapeAnnotation;
|
||||||
|
const rect = pdfRectToScreen(shape.rect, vp);
|
||||||
|
const props = shape.props;
|
||||||
|
const common = {
|
||||||
|
fill: props.fillColor === 'transparent' ? 'transparent' : props.fillColor,
|
||||||
|
stroke: props.strokeColor,
|
||||||
|
strokeWidth: props.strokeWidth,
|
||||||
|
};
|
||||||
|
let object: fabric.FabricObject;
|
||||||
|
if (props.kind === 'ellipse') {
|
||||||
|
object = new fabric.Ellipse({ ...common, left: rect.x, top: rect.y, rx: rect.width / 2, ry: rect.height / 2 });
|
||||||
|
} else if (props.kind === 'line') {
|
||||||
|
const start = props.start ?? [0, 0];
|
||||||
|
const end = props.end ?? [1, 1];
|
||||||
|
const startPoint = { x: rect.x + start[0] * rect.width, y: rect.y + start[1] * rect.height };
|
||||||
|
const endPoint = { x: rect.x + end[0] * rect.width, y: rect.y + end[1] * rect.height };
|
||||||
|
const left = Math.min(startPoint.x, endPoint.x);
|
||||||
|
const top = Math.min(startPoint.y, endPoint.y);
|
||||||
|
object = new fabric.Line(
|
||||||
|
[startPoint.x - left, startPoint.y - top, endPoint.x - left, endPoint.y - top],
|
||||||
|
{ ...common, left, top },
|
||||||
|
);
|
||||||
|
} else if (props.kind === 'arrow') {
|
||||||
|
const start = props.start ?? [0, 0];
|
||||||
|
const end = props.end ?? [1, 1];
|
||||||
|
const startPoint = { x: rect.x + start[0] * rect.width, y: rect.y + start[1] * rect.height };
|
||||||
|
const endPoint = { x: rect.x + end[0] * rect.width, y: rect.y + end[1] * rect.height };
|
||||||
|
object = createArrow(startPoint, endPoint);
|
||||||
|
object.set(common);
|
||||||
|
} else {
|
||||||
|
object = new fabric.Rect({ ...common, left: rect.x, top: rect.y, width: rect.width, height: rect.height });
|
||||||
|
}
|
||||||
|
style(object);
|
||||||
|
object.set({ id: shape.id });
|
||||||
|
canvas.add(object);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -17,6 +17,10 @@ const FONTS = [
|
||||||
'Caveat'
|
'Caveat'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface AssetUploadResponse {
|
||||||
|
ref: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) {
|
export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) {
|
||||||
const [tab, setTab] = useState<'type' | 'draw'>('type');
|
const [tab, setTab] = useState<'type' | 'draw'>('type');
|
||||||
const [text, setText] = useState('John Doe');
|
const [text, setText] = useState('John Doe');
|
||||||
|
|
@ -116,7 +120,7 @@ export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', blob, 'signature.png');
|
formData.append('file', blob, 'signature.png');
|
||||||
|
|
||||||
const data = await api.upload<any>(`/documents/${documentId}/assets`, formData);
|
const data = await api.upload<AssetUploadResponse>(`/documents/${documentId}/assets`, formData);
|
||||||
|
|
||||||
onConfirm({
|
onConfirm({
|
||||||
mode: 'draw',
|
mode: 'draw',
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,18 @@ import type { Annotation, SignatureAnnotation, SignatureDrawProps, SignatureType
|
||||||
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords';
|
||||||
import type { ViewportParams } from '../../../lib/coords';
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
|
|
||||||
|
type SignatureProps = SignatureDrawProps | SignatureTypeProps;
|
||||||
|
type FabricSignatureObject = fabric.FabricObject & {
|
||||||
|
id?: string;
|
||||||
|
annotationType?: string;
|
||||||
|
annotationProps?: SignatureProps;
|
||||||
|
};
|
||||||
|
|
||||||
let previewObj: fabric.FabricObject | null = null;
|
let previewObj: fabric.FabricObject | null = null;
|
||||||
let currentPreviewCanvas: fabric.Canvas | null = null;
|
let currentPreviewCanvas: fabric.Canvas | null = null;
|
||||||
let isCreatingPreview = false;
|
let isCreatingPreview = false;
|
||||||
|
|
||||||
const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: any) => {
|
const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: SignatureProps) => {
|
||||||
isCreatingPreview = true;
|
isCreatingPreview = true;
|
||||||
const storeState = useEditorStore.getState();
|
const storeState = useEditorStore.getState();
|
||||||
if (props.mode === 'draw') {
|
if (props.mode === 'draw') {
|
||||||
|
|
@ -23,11 +30,13 @@ const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: a
|
||||||
}
|
}
|
||||||
img.set({ originX: 'center', originY: 'center', opacity: 0.5, evented: false });
|
img.set({ originX: 'center', originY: 'center', opacity: 0.5, evented: false });
|
||||||
previewObj = img;
|
previewObj = img;
|
||||||
} catch (e) {}
|
} catch {
|
||||||
|
previewObj = null;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
previewObj = new fabric.Text(props.text, {
|
previewObj = new fabric.Text(props.text, {
|
||||||
fontFamily: props.fontFamily,
|
fontFamily: props.fontFamily,
|
||||||
fontSize: 48 * vp.scale * (vp.dpr || 1),
|
fontSize: 48 * vp.scale,
|
||||||
fill: props.color,
|
fill: props.color,
|
||||||
originX: 'center',
|
originX: 'center',
|
||||||
originY: 'center',
|
originY: 'center',
|
||||||
|
|
@ -61,7 +70,7 @@ export const SignatureTool: ToolHandler = {
|
||||||
isCreatingPreview = false;
|
isCreatingPreview = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
onPointerMove: (e: any, canvas: fabric.Canvas, vp: ViewportParams, _pageNumber: number) => {
|
onPointerMove: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||||
const props = useEditorStore.getState().pendingSignatureProps;
|
const props = useEditorStore.getState().pendingSignatureProps;
|
||||||
if (!props) return;
|
if (!props) return;
|
||||||
|
|
||||||
|
|
@ -86,7 +95,7 @@ export const SignatureTool: ToolHandler = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||||
if (e.target && e.target !== previewObj) return;
|
if (e.target && e.target !== previewObj) return;
|
||||||
|
|
||||||
const storeState = useEditorStore.getState();
|
const storeState = useEditorStore.getState();
|
||||||
|
|
@ -120,7 +129,7 @@ export const SignatureTool: ToolHandler = {
|
||||||
originY: 'center',
|
originY: 'center',
|
||||||
});
|
});
|
||||||
// We set id on the object so it can be identified
|
// We set id on the object so it can be identified
|
||||||
(img as any).id = uuidv4();
|
(img as FabricSignatureObject).id = uuidv4();
|
||||||
fabricObj = img;
|
fabricObj = img;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load signature image", err);
|
console.error("Failed to load signature image", err);
|
||||||
|
|
@ -132,12 +141,12 @@ export const SignatureTool: ToolHandler = {
|
||||||
left: pointer.x,
|
left: pointer.x,
|
||||||
top: pointer.y,
|
top: pointer.y,
|
||||||
fontFamily: typeProps.fontFamily,
|
fontFamily: typeProps.fontFamily,
|
||||||
fontSize: 48 * vp.scale * (vp.dpr || 1),
|
fontSize: 48 * vp.scale,
|
||||||
fill: typeProps.color,
|
fill: typeProps.color,
|
||||||
originX: 'center',
|
originX: 'center',
|
||||||
originY: 'center',
|
originY: 'center',
|
||||||
});
|
});
|
||||||
(textObj as any).id = uuidv4();
|
(textObj as FabricSignatureObject).id = uuidv4();
|
||||||
fabricObj = textObj;
|
fabricObj = textObj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,8 +159,9 @@ export const SignatureTool: ToolHandler = {
|
||||||
padding: 5,
|
padding: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
(fabricObj as any).annotationType = 'signature';
|
const annotatedObject = fabricObj as FabricSignatureObject;
|
||||||
(fabricObj as any).annotationProps = props;
|
annotatedObject.annotationType = 'signature';
|
||||||
|
annotatedObject.annotationProps = props;
|
||||||
|
|
||||||
canvas.add(fabricObj);
|
canvas.add(fabricObj);
|
||||||
canvas.setActiveObject(fabricObj);
|
canvas.setActiveObject(fabricObj);
|
||||||
|
|
@ -167,7 +177,7 @@ export const SignatureTool: ToolHandler = {
|
||||||
}, vp);
|
}, vp);
|
||||||
|
|
||||||
const annotation: SignatureAnnotation = {
|
const annotation: SignatureAnnotation = {
|
||||||
id: (fabricObj as any).id,
|
id: annotatedObject.id as string,
|
||||||
page: pageNumber,
|
page: pageNumber,
|
||||||
type: 'signature',
|
type: 'signature',
|
||||||
rect: pdfRect,
|
rect: pdfRect,
|
||||||
|
|
@ -186,7 +196,7 @@ export const SignatureTool: ToolHandler = {
|
||||||
},
|
},
|
||||||
|
|
||||||
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||||
if (annotation.type !== 'signature') return null;
|
if (annotation.type !== 'signature') return;
|
||||||
const sigAnn = annotation as SignatureAnnotation;
|
const sigAnn = annotation as SignatureAnnotation;
|
||||||
const screenRect = pdfRectToScreen(sigAnn.rect, vp);
|
const screenRect = pdfRectToScreen(sigAnn.rect, vp);
|
||||||
|
|
||||||
|
|
@ -199,19 +209,23 @@ export const SignatureTool: ToolHandler = {
|
||||||
img.set({
|
img.set({
|
||||||
left: screenRect.x,
|
left: screenRect.x,
|
||||||
top: screenRect.y,
|
top: screenRect.y,
|
||||||
|
originX: 'left',
|
||||||
|
originY: 'top',
|
||||||
scaleX: screenRect.width / img.width!,
|
scaleX: screenRect.width / img.width!,
|
||||||
scaleY: screenRect.height / img.height!,
|
scaleY: screenRect.height / img.height!,
|
||||||
});
|
});
|
||||||
fabricObj = img;
|
fabricObj = img;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load signature asset", e);
|
console.error("Failed to load signature asset", e);
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const typeProps = sigAnn.props as SignatureTypeProps;
|
const typeProps = sigAnn.props as SignatureTypeProps;
|
||||||
fabricObj = new fabric.Text(typeProps.text, {
|
fabricObj = new fabric.Text(typeProps.text, {
|
||||||
left: screenRect.x,
|
left: screenRect.x,
|
||||||
top: screenRect.y,
|
top: screenRect.y,
|
||||||
|
originX: 'left',
|
||||||
|
originY: 'top',
|
||||||
fontFamily: typeProps.fontFamily,
|
fontFamily: typeProps.fontFamily,
|
||||||
fill: typeProps.color,
|
fill: typeProps.color,
|
||||||
});
|
});
|
||||||
|
|
@ -222,17 +236,18 @@ export const SignatureTool: ToolHandler = {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const annotatedObject = fabricObj as FabricSignatureObject;
|
||||||
|
annotatedObject.id = sigAnn.id;
|
||||||
|
annotatedObject.annotationType = 'signature';
|
||||||
|
annotatedObject.annotationProps = sigAnn.props;
|
||||||
fabricObj.set({
|
fabricObj.set({
|
||||||
id: sigAnn.id,
|
|
||||||
annotationType: 'signature',
|
|
||||||
annotationProps: sigAnn.props,
|
|
||||||
transparentCorners: false,
|
transparentCorners: false,
|
||||||
cornerColor: '#3b82f6',
|
cornerColor: '#3b82f6',
|
||||||
cornerStrokeColor: '#3b82f6',
|
cornerStrokeColor: '#3b82f6',
|
||||||
borderColor: '#3b82f6',
|
borderColor: '#3b82f6',
|
||||||
cornerSize: 8,
|
cornerSize: 8,
|
||||||
padding: 5,
|
padding: 5,
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
canvas.add(fabricObj);
|
canvas.add(fabricObj);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,14 @@ import { v4 as uuidv4 } from 'uuid';
|
||||||
import { useEditorStore } from '../store';
|
import { useEditorStore } from '../store';
|
||||||
import type { ToolHandler } from '../../../lib/annotations/registry';
|
import type { ToolHandler } from '../../../lib/annotations/registry';
|
||||||
import type { Annotation, TextAnnotation } from '../../../lib/annotations/types';
|
import type { Annotation, TextAnnotation } from '../../../lib/annotations/types';
|
||||||
import { screenToPdf, pdfRectToScreen } from '../../../lib/coords';
|
import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords';
|
||||||
import type { ViewportParams } from '../../../lib/coords';
|
import type { ViewportParams } from '../../../lib/coords';
|
||||||
|
|
||||||
|
type FabricTextObject = fabric.Textbox & {
|
||||||
|
id?: string;
|
||||||
|
customHeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const TextTool: ToolHandler = {
|
export const TextTool: ToolHandler = {
|
||||||
name: 'text',
|
name: 'text',
|
||||||
|
|
||||||
|
|
@ -18,7 +23,7 @@ export const TextTool: ToolHandler = {
|
||||||
canvas.defaultCursor = 'default';
|
canvas.defaultCursor = 'default';
|
||||||
},
|
},
|
||||||
|
|
||||||
onPointerDown: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
onPointerDown: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => {
|
||||||
// If we clicked on an existing object, don't create a new one
|
// If we clicked on an existing object, don't create a new one
|
||||||
if (e.target) return;
|
if (e.target) return;
|
||||||
|
|
||||||
|
|
@ -39,7 +44,7 @@ export const TextTool: ToolHandler = {
|
||||||
top: pointer.y,
|
top: pointer.y,
|
||||||
width: 150 * vp.scale,
|
width: 150 * vp.scale,
|
||||||
fontFamily: fontFamily,
|
fontFamily: fontFamily,
|
||||||
fontSize: fontSize * vp.scale * (vp.dpr || 1),
|
fontSize: fontSize * vp.scale,
|
||||||
fontWeight: isBold ? 'bold' : 'normal',
|
fontWeight: isBold ? 'bold' : 'normal',
|
||||||
fontStyle: isItalic ? 'italic' : 'normal',
|
fontStyle: isItalic ? 'italic' : 'normal',
|
||||||
fill: color,
|
fill: color,
|
||||||
|
|
@ -55,12 +60,13 @@ export const TextTool: ToolHandler = {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Allow manual height control
|
// Allow manual height control
|
||||||
(textbox as any).customHeight = textbox.height;
|
const annotatedTextbox = textbox as FabricTextObject;
|
||||||
|
annotatedTextbox.customHeight = textbox.height;
|
||||||
const originalInitDimensions = textbox.initDimensions.bind(textbox);
|
const originalInitDimensions = textbox.initDimensions.bind(textbox);
|
||||||
textbox.initDimensions = function() {
|
textbox.initDimensions = function() {
|
||||||
originalInitDimensions();
|
originalInitDimensions();
|
||||||
if ((this as any).customHeight !== undefined) {
|
if ((this as FabricTextObject).customHeight !== undefined) {
|
||||||
this.height = (this as any).customHeight;
|
this.height = (this as FabricTextObject).customHeight as number;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -94,7 +100,7 @@ export const TextTool: ToolHandler = {
|
||||||
const w = textbox.width! * textbox.scaleX!;
|
const w = textbox.width! * textbox.scaleX!;
|
||||||
const h = textbox.height! * textbox.scaleY!;
|
const h = textbox.height! * textbox.scaleY!;
|
||||||
|
|
||||||
(textbox as any).customHeight = h;
|
annotatedTextbox.customHeight = h;
|
||||||
|
|
||||||
textbox.set({
|
textbox.set({
|
||||||
width: w,
|
width: w,
|
||||||
|
|
@ -106,24 +112,27 @@ export const TextTool: ToolHandler = {
|
||||||
});
|
});
|
||||||
|
|
||||||
const newId = uuidv4();
|
const newId = uuidv4();
|
||||||
(textbox as any).id = newId;
|
annotatedTextbox.id = newId;
|
||||||
|
|
||||||
canvas.add(textbox);
|
canvas.add(textbox);
|
||||||
canvas.setActiveObject(textbox);
|
canvas.setActiveObject(textbox);
|
||||||
textbox.enterEditing();
|
textbox.enterEditing();
|
||||||
|
|
||||||
// Add to store immediately so the toolbar shows up instantly
|
// Add to store immediately so the toolbar shows up instantly
|
||||||
const pt = screenToPdf({ x: textbox.left!, y: textbox.top! }, vp);
|
const initialBounds = textbox.getBoundingRect();
|
||||||
const newAnn: TextAnnotation = {
|
const newAnn: TextAnnotation = {
|
||||||
id: newId,
|
id: newId,
|
||||||
page: pageNumber,
|
page: pageNumber,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
rect: {
|
rect: screenRectToPdf(
|
||||||
x: pt.x,
|
{
|
||||||
y: pt.y,
|
x: initialBounds.left,
|
||||||
width: textbox.width! / (vp.scale * (vp.dpr || 1)),
|
y: initialBounds.top,
|
||||||
height: textbox.height! / (vp.scale * (vp.dpr || 1))
|
width: initialBounds.width,
|
||||||
|
height: initialBounds.height,
|
||||||
},
|
},
|
||||||
|
vp,
|
||||||
|
),
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
z: 0,
|
z: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
|
@ -143,41 +152,49 @@ export const TextTool: ToolHandler = {
|
||||||
storeState.setDraftAnnotation(newAnn);
|
storeState.setDraftAnnotation(newAnn);
|
||||||
|
|
||||||
textbox.on('editing:exited', () => {
|
textbox.on('editing:exited', () => {
|
||||||
// Clear draft
|
|
||||||
useEditorStore.getState().setDraftAnnotation(null);
|
|
||||||
|
|
||||||
if (!textbox.text || textbox.text.trim() === '') {
|
if (!textbox.text || textbox.text.trim() === '') {
|
||||||
|
useEditorStore.getState().setDraftAnnotation(null);
|
||||||
canvas.remove(textbox);
|
canvas.remove(textbox);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get fresh annotation in case it was modified (e.g. bold/italic via toolbar)
|
const state = useEditorStore.getState();
|
||||||
const currentAnn = (useEditorStore.getState().annotations.find(a => a.id === newId) as TextAnnotation) || newAnn;
|
const currentAnn = (state.annotations.find(a => a.id === newId) as TextAnnotation | undefined)
|
||||||
|
?? state.draftAnnotation as TextAnnotation | null
|
||||||
// Update the text and push to main store
|
?? newAnn;
|
||||||
currentAnn.props.text = textbox.text;
|
const bounds = textbox.getBoundingRect();
|
||||||
|
const nextAnnotation: TextAnnotation = {
|
||||||
|
...currentAnn,
|
||||||
|
rect: screenRectToPdf(
|
||||||
|
{ x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height },
|
||||||
|
vp,
|
||||||
|
),
|
||||||
|
props: { ...currentAnn.props, text: textbox.text },
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
// Save inline styles if any exist
|
// Save inline styles if any exist
|
||||||
if ((textbox as any).styles && Object.keys((textbox as any).styles).length > 0) {
|
if (textbox.styles && Object.keys(textbox.styles).length > 0) {
|
||||||
currentAnn.props.styles = JSON.parse(JSON.stringify((textbox as any).styles));
|
nextAnnotation.props.styles = JSON.parse(JSON.stringify(textbox.styles));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEditorStore.getState().addAnnotation(currentAnn);
|
state.setDraftAnnotation(null);
|
||||||
|
state.addAnnotation(nextAnnotation);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => {
|
||||||
const textAnn = annotation as TextAnnotation;
|
const textAnn = annotation as TextAnnotation;
|
||||||
const pt = pdfRectToScreen(textAnn.rect, vp);
|
const pt = pdfRectToScreen(textAnn.rect, vp);
|
||||||
const boxHeight = textAnn.rect.height * vp.scale * (vp.dpr || 1);
|
const boxHeight = textAnn.rect.height * vp.scale;
|
||||||
|
|
||||||
const textbox = new fabric.Textbox(textAnn.props.text, {
|
const textbox = new fabric.Textbox(textAnn.props.text, {
|
||||||
left: pt.x,
|
left: pt.x,
|
||||||
top: pt.y,
|
top: pt.y,
|
||||||
width: textAnn.rect.width * vp.scale * (vp.dpr || 1),
|
width: textAnn.rect.width * vp.scale,
|
||||||
height: boxHeight,
|
height: boxHeight,
|
||||||
fontFamily: textAnn.props.fontFamily,
|
fontFamily: textAnn.props.fontFamily,
|
||||||
fontSize: textAnn.props.fontSize * vp.scale * (vp.dpr || 1),
|
fontSize: textAnn.props.fontSize * vp.scale,
|
||||||
fontWeight: textAnn.props.bold ? 'bold' : 'normal',
|
fontWeight: textAnn.props.bold ? 'bold' : 'normal',
|
||||||
fontStyle: textAnn.props.italic ? 'italic' : 'normal',
|
fontStyle: textAnn.props.italic ? 'italic' : 'normal',
|
||||||
fill: textAnn.props.color,
|
fill: textAnn.props.color,
|
||||||
|
|
@ -194,12 +211,13 @@ export const TextTool: ToolHandler = {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Allow manual height control
|
// Allow manual height control
|
||||||
(textbox as any).customHeight = boxHeight;
|
const annotatedTextbox = textbox as FabricTextObject;
|
||||||
|
annotatedTextbox.customHeight = boxHeight;
|
||||||
const originalInitDimensions = textbox.initDimensions.bind(textbox);
|
const originalInitDimensions = textbox.initDimensions.bind(textbox);
|
||||||
textbox.initDimensions = function() {
|
textbox.initDimensions = function() {
|
||||||
originalInitDimensions();
|
originalInitDimensions();
|
||||||
if ((this as any).customHeight !== undefined) {
|
if ((this as FabricTextObject).customHeight !== undefined) {
|
||||||
this.height = (this as any).customHeight;
|
this.height = (this as FabricTextObject).customHeight as number;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -232,7 +250,7 @@ export const TextTool: ToolHandler = {
|
||||||
const w = textbox.width! * textbox.scaleX!;
|
const w = textbox.width! * textbox.scaleX!;
|
||||||
const h = textbox.height! * textbox.scaleY!;
|
const h = textbox.height! * textbox.scaleY!;
|
||||||
|
|
||||||
(textbox as any).customHeight = h;
|
annotatedTextbox.customHeight = h;
|
||||||
|
|
||||||
textbox.set({
|
textbox.set({
|
||||||
width: w,
|
width: w,
|
||||||
|
|
@ -248,16 +266,16 @@ export const TextTool: ToolHandler = {
|
||||||
const currentAnn = useEditorStore.getState().annotations.find(a => a.id === annotation.id) as TextAnnotation | undefined;
|
const currentAnn = useEditorStore.getState().annotations.find(a => a.id === annotation.id) as TextAnnotation | undefined;
|
||||||
if (!currentAnn) return;
|
if (!currentAnn) return;
|
||||||
|
|
||||||
const updates: any = { text: textbox.text };
|
const updates: Partial<TextAnnotation['props']> = { text: textbox.text };
|
||||||
if ((textbox as any).styles && Object.keys((textbox as any).styles).length > 0) {
|
if (textbox.styles && Object.keys(textbox.styles).length > 0) {
|
||||||
updates.styles = JSON.parse(JSON.stringify((textbox as any).styles));
|
updates.styles = JSON.parse(JSON.stringify(textbox.styles)) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
useEditorStore.getState().updateAnnotation(annotation.id, {
|
useEditorStore.getState().updateAnnotation(annotation.id, {
|
||||||
props: { ...currentAnn.props, ...updates }
|
props: { ...currentAnn.props, ...updates }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
(textbox as any).id = annotation.id;
|
annotatedTextbox.id = annotation.id;
|
||||||
canvas.add(textbox);
|
canvas.add(textbox);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,16 @@ import { TextTool } from './TextTool';
|
||||||
import { SignatureTool } from './SignatureTool';
|
import { SignatureTool } from './SignatureTool';
|
||||||
import { ImageTool } from './ImageTool';
|
import { ImageTool } from './ImageTool';
|
||||||
import { DrawTool } from './DrawTool';
|
import { DrawTool } from './DrawTool';
|
||||||
|
import { HighlightTool } from './HighlightTool';
|
||||||
|
import { ShapeTool } from './ShapeTool';
|
||||||
|
|
||||||
// Register all tools here
|
// Register all tools here
|
||||||
registerTool(TextTool);
|
registerTool(TextTool);
|
||||||
registerTool(SignatureTool);
|
registerTool(SignatureTool);
|
||||||
registerTool(ImageTool);
|
registerTool(ImageTool);
|
||||||
registerTool(DrawTool);
|
registerTool(DrawTool);
|
||||||
|
registerTool(HighlightTool);
|
||||||
|
registerTool(ShapeTool);
|
||||||
|
|
||||||
// SelectTool implementation
|
// SelectTool implementation
|
||||||
registerTool({
|
registerTool({
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,135 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useEditorStore } from './store';
|
import { api, ApiRequestError } from '../../lib/api/client';
|
||||||
import type { Annotation } from '../../lib/annotations/types';
|
import type { Annotation } from '../../lib/annotations/types';
|
||||||
|
import { useEditorStore } from './store';
|
||||||
|
|
||||||
export function useAutosave() {
|
interface AnnotationResponse {
|
||||||
const { documentId, annotations, setSaveStatus, setAnnotations } = useEditorStore();
|
data: Annotation[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnotationUpdateResponse {
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutosave(documentId: string | undefined) {
|
||||||
|
const loadedDocumentId = useRef<string | null>(null);
|
||||||
|
const skipNextSave = useRef(false);
|
||||||
const timeoutRef = useRef<number | null>(null);
|
const timeoutRef = useRef<number | null>(null);
|
||||||
const isFirstLoad = useRef(true);
|
const saveGeneration = useRef(0);
|
||||||
|
const setDocumentId = useEditorStore((state) => state.setDocumentId);
|
||||||
|
const setAnnotations = useEditorStore((state) => state.setAnnotations);
|
||||||
|
const setAnnotationUpdatedAt = useEditorStore((state) => state.setAnnotationUpdatedAt);
|
||||||
|
const setSaveStatus = useEditorStore((state) => state.setSaveStatus);
|
||||||
|
const storeDocumentId = useEditorStore((state) => state.documentId);
|
||||||
|
const annotations = useEditorStore((state) => state.annotations);
|
||||||
|
|
||||||
// Initial load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!documentId) return;
|
saveGeneration.current += 1;
|
||||||
|
if (timeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setDocumentId(documentId ?? null);
|
||||||
|
loadedDocumentId.current = null;
|
||||||
|
skipNextSave.current = true;
|
||||||
|
}, [documentId, setDocumentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!documentId || storeDocumentId !== documentId) return;
|
||||||
let active = true;
|
let active = true;
|
||||||
|
setSaveStatus('loading');
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/documents/${documentId}/annotations`);
|
const response = await api.get<AnnotationResponse>(`/documents/${documentId}/annotations`);
|
||||||
if (!res.ok) throw new Error('Failed to load annotations');
|
if (!active || useEditorStore.getState().documentId !== documentId) return;
|
||||||
const data = await res.json();
|
skipNextSave.current = true;
|
||||||
|
setAnnotations(response.data);
|
||||||
if (active) {
|
setAnnotationUpdatedAt(response.updatedAt);
|
||||||
setAnnotations(data.data as Annotation[]);
|
loadedDocumentId.current = documentId;
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
isFirstLoad.current = false;
|
} catch (error) {
|
||||||
}
|
if (active && useEditorStore.getState().documentId === documentId) {
|
||||||
} catch (err) {
|
console.error('Error loading annotations:', error);
|
||||||
console.error('Error loading annotations:', err);
|
|
||||||
if (active) setSaveStatus('error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
|
|
||||||
return () => { active = false; };
|
|
||||||
}, [documentId, setAnnotations, setSaveStatus]);
|
|
||||||
|
|
||||||
// Debounced save
|
|
||||||
useEffect(() => {
|
|
||||||
// Don't save on the initial load!
|
|
||||||
if (isFirstLoad.current) return;
|
|
||||||
if (!documentId) return;
|
|
||||||
|
|
||||||
setSaveStatus('saving');
|
|
||||||
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
window.clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timeoutRef.current = window.setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/v1/documents/${documentId}/annotations`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
data: annotations
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error('Failed to save');
|
|
||||||
setSaveStatus('saved');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error saving annotations:', err);
|
|
||||||
setSaveStatus('error');
|
setSaveStatus('error');
|
||||||
}
|
}
|
||||||
}, 500); // 500ms debounce
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [documentId, setAnnotationUpdatedAt, setAnnotations, setSaveStatus, storeDocumentId]);
|
||||||
|
|
||||||
|
const flush = useCallback(async () => {
|
||||||
|
if (!documentId || useEditorStore.getState().documentId !== documentId || loadedDocumentId.current !== documentId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (timeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
const generation = ++saveGeneration.current;
|
||||||
|
const snapshot = useEditorStore.getState();
|
||||||
|
const payload = {
|
||||||
|
data: snapshot.annotations,
|
||||||
|
baseUpdatedAt: snapshot.annotationUpdatedAt ?? undefined,
|
||||||
|
};
|
||||||
|
setSaveStatus('saving');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.put<AnnotationUpdateResponse>(`/documents/${documentId}/annotations`, payload)
|
||||||
|
.catch(async (error: unknown) => {
|
||||||
|
if (error instanceof ApiRequestError && error.status === 409) {
|
||||||
|
return api.put<AnnotationUpdateResponse>(`/documents/${documentId}/annotations`, {
|
||||||
|
data: snapshot.annotations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) {
|
||||||
|
setAnnotationUpdatedAt(response.updatedAt);
|
||||||
|
setSaveStatus('saved');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) {
|
||||||
|
console.error('Error saving annotations:', error);
|
||||||
|
setSaveStatus('error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [documentId, setAnnotationUpdatedAt, setSaveStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!documentId || storeDocumentId !== documentId || loadedDocumentId.current !== documentId) return;
|
||||||
|
if (skipNextSave.current) {
|
||||||
|
skipNextSave.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveStatus('saving');
|
||||||
|
if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
|
timeoutRef.current = null;
|
||||||
|
void flush();
|
||||||
|
}, 500);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
saveGeneration.current += 1;
|
||||||
};
|
if (timeoutRef.current !== null) {
|
||||||
}, [annotations, documentId, setSaveStatus]);
|
window.clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [annotations, documentId, flush, setSaveStatus, storeDocumentId]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
saveGeneration.current += 1;
|
||||||
|
if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { flush };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
236
frontend/src/features/editor/versions/VersionPanel.tsx
Normal file
236
frontend/src/features/editor/versions/VersionPanel.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Clock3, RotateCcw, Save, Trash2, X } from 'lucide-react';
|
||||||
|
import { api } from '../../../lib/api/client';
|
||||||
|
import type { Annotation } from '../../../lib/annotations/types';
|
||||||
|
import { useEditorStore } from '../store';
|
||||||
|
|
||||||
|
interface VersionMeta {
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
label: string | null;
|
||||||
|
kind: 'manual' | 'auto';
|
||||||
|
createdAt: string;
|
||||||
|
annotationCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionListResponse {
|
||||||
|
items: VersionMeta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnotationResponse {
|
||||||
|
data: Annotation[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionPanelProps {
|
||||||
|
documentId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVersionDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionPanel({ documentId, onClose }: VersionPanelProps) {
|
||||||
|
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const setAnnotations = useEditorStore((state) => state.setAnnotations);
|
||||||
|
const setAnnotationUpdatedAt = useEditorStore((state) => state.setAnnotationUpdatedAt);
|
||||||
|
|
||||||
|
const loadVersions = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await api.get<VersionListResponse>(`/documents/${documentId}/versions`);
|
||||||
|
setVersions(response.items);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'Unable to load checkpoints.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [documentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => void loadVersions(), 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [loadVersions]);
|
||||||
|
|
||||||
|
const createCheckpoint = async () => {
|
||||||
|
setBusyId('create');
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.post<VersionMeta>(`/documents/${documentId}/versions`, {
|
||||||
|
label: label.trim() || null,
|
||||||
|
kind: 'manual',
|
||||||
|
});
|
||||||
|
setLabel('');
|
||||||
|
await loadVersions();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'Unable to create checkpoint.');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreVersion = async (version: VersionMeta) => {
|
||||||
|
if (!window.confirm(`Restore “${version.label || 'Automatic checkpoint'}”? Your current work will be saved first.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusyId(version.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const restored = await api.post<{ updatedAt: string }>(
|
||||||
|
`/documents/${documentId}/versions/${version.id}/restore`,
|
||||||
|
);
|
||||||
|
const current = await api.get<AnnotationResponse>(`/documents/${documentId}/annotations`);
|
||||||
|
setAnnotations(current.data);
|
||||||
|
setAnnotationUpdatedAt(restored.updatedAt || current.updatedAt);
|
||||||
|
await loadVersions();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'Unable to restore checkpoint.');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteVersion = async (version: VersionMeta) => {
|
||||||
|
if (!window.confirm('Delete this checkpoint permanently?')) return;
|
||||||
|
setBusyId(version.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.delete(`/documents/${documentId}/versions/${version.id}`);
|
||||||
|
setVersions((current) => current.filter((item) => item.id !== version.id));
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'Unable to delete checkpoint.');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="fixed inset-y-0 right-0 z-[80] flex w-full max-w-md flex-col border-l border-neutral-200 bg-white shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
|
||||||
|
<Clock3 className="h-5 w-5 text-accent-600" />
|
||||||
|
Checkpoints
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-neutral-500">Recover annotation work without changing the original PDF.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="rounded-lg p-2 text-neutral-500 hover:bg-neutral-100" aria-label="Close checkpoints">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b border-neutral-200 bg-neutral-50 px-5 py-4">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-neutral-500" htmlFor="checkpoint-label">
|
||||||
|
Save current work
|
||||||
|
</label>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
id="checkpoint-label"
|
||||||
|
value={label}
|
||||||
|
onChange={(event) => setLabel(event.target.value)}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Optional label"
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-accent-500 focus:ring-2 focus:ring-accent-100"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void createCheckpoint()}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-2 text-sm font-semibold text-white hover:bg-accent-700 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm('Clear every annotation? A recovery checkpoint will be created first.')) return;
|
||||||
|
setBusyId('clear');
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.post(`/documents/${documentId}/versions`, { label: 'Before clear all', kind: 'auto' });
|
||||||
|
setAnnotations([]);
|
||||||
|
await loadVersions();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'Unable to clear annotations.');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
className="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold text-red-600 hover:text-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Clear all annotations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mx-5 mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-12 text-center text-sm text-neutral-500">Loading checkpoints…</div>
|
||||||
|
) : versions.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-neutral-300 px-5 py-10 text-center">
|
||||||
|
<Clock3 className="mx-auto h-8 w-8 text-neutral-300" />
|
||||||
|
<p className="mt-3 text-sm font-medium text-neutral-700">No checkpoints yet</p>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">Save one before a major edit or export.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{versions.map((version) => {
|
||||||
|
const busy = busyId === version.id;
|
||||||
|
return (
|
||||||
|
<div key={version.id} className="rounded-xl border border-neutral-200 bg-white p-3 shadow-sm">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-neutral-800">{version.label || 'Automatic checkpoint'}</p>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
{formatVersionDate(version.createdAt)} · {version.annotationCount} annotation{version.annotationCount === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${version.kind === 'manual' ? 'bg-accent-100 text-accent-700' : 'bg-neutral-100 text-neutral-500'}`}>
|
||||||
|
{version.kind}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void restoreVersion(version)}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 px-2.5 py-1.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
{busy ? 'Restoring…' : 'Restore'}
|
||||||
|
</button>
|
||||||
|
{version.kind === 'manual' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void deleteVersion(version)}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
className="rounded-lg p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||||
|
aria-label="Delete checkpoint"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import { formatDistanceToNow } from 'date-fns'
|
||||||
import { ContextMenu } from './ContextMenu'
|
import { ContextMenu } from './ContextMenu'
|
||||||
|
|
||||||
export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
|
export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
|
||||||
const { documents, selectedIds, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary()
|
const { documents, selectedIds, isLoading, error, toggleSelection, deleteDocument, restoreDocument, updateDocument } = useLibrary()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null)
|
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null)
|
||||||
|
|
@ -61,6 +61,16 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
|
||||||
isSelected ? 'border-accent-500 shadow-md ring-2 ring-accent-500/50 scale-[1.02]' : 'hover:border-accent-300 dark:hover:border-accent-700'
|
isSelected ? 'border-accent-500 shadow-md ring-2 ring-accent-500/50 scale-[1.02]' : 'hover:border-accent-300 dark:hover:border-accent-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<label className="absolute right-3 top-3 z-20 flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border border-white/80 bg-white/90 shadow-sm backdrop-blur transition-opacity group-hover:opacity-100 sm:opacity-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleSelection(doc.id)}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
className="h-4 w-4 rounded border-neutral-300 text-accent-600 focus:ring-accent-500"
|
||||||
|
aria-label={`Select ${doc.title}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="absolute top-3 left-3 z-20 w-6 h-6 bg-accent-500 rounded-full flex items-center justify-center shadow-md ring-2 ring-white">
|
<div className="absolute top-3 left-3 z-20 w-6 h-6 bg-accent-500 rounded-full flex items-center justify-center shadow-md ring-2 ring-white">
|
||||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|
@ -127,9 +137,13 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDelete={() => deleteDocument(contextMenu.docId)}
|
onDelete={() => {
|
||||||
|
if (window.confirm('Move this document to Trash?')) void deleteDocument(contextMenu.docId)
|
||||||
|
}}
|
||||||
onRestore={() => restoreDocument(contextMenu.docId)}
|
onRestore={() => restoreDocument(contextMenu.docId)}
|
||||||
onHardDelete={() => deleteDocument(contextMenu.docId, true)}
|
onHardDelete={() => {
|
||||||
|
if (window.confirm('Delete this document permanently? This cannot be undone.')) void deleteDocument(contextMenu.docId, true)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,9 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar
|
||||||
Restore
|
Restore
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => bulkDelete(true)}
|
onClick={() => {
|
||||||
|
if (window.confirm('Delete the selected documents permanently? This cannot be undone.')) void bulkDelete(true)
|
||||||
|
}}
|
||||||
className="text-sm text-red-600 dark:text-red-400 hover:text-white hover:bg-red-500 font-bold px-3 py-1.5 rounded-full transition-colors ml-1"
|
className="text-sm text-red-600 dark:text-red-400 hover:text-white hover:bg-red-500 font-bold px-3 py-1.5 rounded-full transition-colors ml-1"
|
||||||
>
|
>
|
||||||
Delete Forever
|
Delete Forever
|
||||||
|
|
@ -123,7 +125,9 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar
|
||||||
|
|
||||||
{currentTab === 'trash' && (
|
{currentTab === 'trash' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => emptyTrash()}
|
onClick={() => {
|
||||||
|
if (window.confirm('Empty Trash permanently? This cannot be undone.')) void emptyTrash()
|
||||||
|
}}
|
||||||
className="text-sm font-bold text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 transition-colors px-4 py-2 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20"
|
className="text-sm font-bold text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 transition-colors px-4 py-2 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
>
|
>
|
||||||
Empty Trash
|
Empty Trash
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,74 @@
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState, type ReactNode } from 'react';
|
||||||
import { useLibrary } from './useLibrary'
|
import { useLibrary } from './useLibrary';
|
||||||
|
import { UploadPickerContext, useUploadPicker } from './upload-context';
|
||||||
|
|
||||||
export const UploadArea = ({ children }: { children: React.ReactNode }) => {
|
export function UploadTrigger({ children, className }: { children: ReactNode; className: string }) {
|
||||||
const { uploadDocument } = useLibrary()
|
const openPicker = useUploadPicker();
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
return <button type="button" onClick={openPicker} className={className}>{children}</button>;
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
export const UploadArea = ({ children }: { children: ReactNode }) => {
|
||||||
e.preventDefault()
|
const { uploadDocument } = useLibrary();
|
||||||
e.stopPropagation()
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
setIsDragging(false)
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = async (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(false)
|
|
||||||
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
||||||
await processFiles(e.dataTransfer.files)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
|
||||||
await processFiles(e.target.files)
|
|
||||||
// Reset input so the same file can be selected again
|
|
||||||
e.target.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processFiles = async (files: FileList) => {
|
const processFiles = async (files: FileList) => {
|
||||||
// For now, process one by one
|
for (let index = 0; index < files.length; index += 1) {
|
||||||
for (let i = 0; i < files.length; i++) {
|
const file = files[index];
|
||||||
const file = files[i]
|
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) continue;
|
||||||
if (file.type === 'application/pdf') {
|
|
||||||
try {
|
try {
|
||||||
await uploadDocument(file)
|
await uploadDocument(file);
|
||||||
} catch (err) {
|
} catch (reason) {
|
||||||
console.error('Failed to upload', file.name, err)
|
console.error('Failed to upload', file.name, reason);
|
||||||
// Could show toast notification here
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
if (event.dataTransfer.files.length > 0) await processFiles(event.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (event.target.files && event.target.files.length > 0) await processFiles(event.target.files);
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<UploadPickerContext.Provider value={() => fileInputRef.current?.click()}>
|
||||||
className="relative min-h-screen"
|
<div className="relative min-h-screen" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={(event) => void handleDrop(event)}>
|
||||||
onDragOver={handleDragOver}
|
<input ref={fileInputRef} type="file" accept="application/pdf" onChange={(event) => void handleFileChange(event)} className="hidden" multiple />
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="application/pdf"
|
|
||||||
ref={fileInputRef}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
className="hidden"
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Invisible overlay that appears when dragging to prevent flickering */}
|
|
||||||
{isDragging && (
|
{isDragging && (
|
||||||
<div className="absolute inset-0 z-50 bg-accent-500/10 dark:bg-accent-900/20 backdrop-blur-md border-4 border-dashed border-accent-400 dark:border-accent-500 rounded-3xl m-4 flex items-center justify-center transition-all pointer-events-none">
|
<div className="pointer-events-none absolute inset-0 z-50 m-4 flex items-center justify-center rounded-3xl border-4 border-dashed border-accent-400 bg-accent-500/10 backdrop-blur-md">
|
||||||
<div className="bg-white/95 dark:bg-gray-800/95 px-10 py-8 rounded-3xl shadow-2xl shadow-accent-500/20 flex flex-col items-center transform scale-105 transition-transform duration-300 border border-white/50">
|
<div className="flex flex-col items-center rounded-3xl border border-white/50 bg-white/95 px-10 py-8 shadow-2xl">
|
||||||
<div className="w-20 h-20 bg-gradient-to-br from-accent-100 to-accent-200 dark:from-accent-800 dark:to-accent-900 rounded-full flex items-center justify-center mb-6 shadow-inner">
|
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-accent-100 shadow-inner">
|
||||||
<svg className="w-10 h-10 text-accent-600 dark:text-accent-300 animate-bounce" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-10 w-10 animate-bounce text-accent-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-3xl font-heading font-bold text-gray-900 dark:text-white tracking-tight">Drop PDFs here</h3>
|
<h3 className="text-3xl font-heading font-bold tracking-tight text-neutral-900">Drop PDFs here</h3>
|
||||||
<p className="text-gray-500 dark:text-gray-400 mt-2 font-medium">Release to upload to your library</p>
|
<p className="mt-2 font-medium text-neutral-500">Release to upload to your library</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
</UploadPickerContext.Provider>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
9
frontend/src/features/library/upload-context.ts
Normal file
9
frontend/src/features/library/upload-context.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export const UploadPickerContext = createContext<(() => void) | null>(null);
|
||||||
|
|
||||||
|
export function useUploadPicker() {
|
||||||
|
const openPicker = useContext(UploadPickerContext);
|
||||||
|
if (!openPicker) throw new Error('useUploadPicker must be used inside UploadArea');
|
||||||
|
return openPicker;
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,17 @@ interface LibraryState {
|
||||||
updateDocument: (id: string, data: DocumentUpdateRequest) => Promise<void>
|
updateDocument: (id: string, data: DocumentUpdateRequest) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function errorMessage(reason: unknown, fallback = 'Something went wrong.') {
|
||||||
|
if (reason instanceof Error && reason.message) return reason.message;
|
||||||
|
if (typeof reason === 'object' && reason !== null && 'error' in reason) {
|
||||||
|
const error = reason.error;
|
||||||
|
if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export const useLibrary = create<LibraryState>((set, get) => ({
|
export const useLibrary = create<LibraryState>((set, get) => ({
|
||||||
documents: [],
|
documents: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
|
|
@ -56,8 +67,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
||||||
try {
|
try {
|
||||||
const data = await libraryApi.getDocuments(query)
|
const data = await libraryApi.getDocuments(query)
|
||||||
set({ documents: data.items, total: data.total, isLoading: false })
|
set({ documents: data.items, total: data.total, isLoading: false })
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.error?.message || err.message, isLoading: false })
|
set({ error: errorMessage(err), isLoading: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -66,8 +77,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
||||||
try {
|
try {
|
||||||
const data = await libraryApi.getTrash()
|
const data = await libraryApi.getTrash()
|
||||||
set({ documents: data.items, total: data.total, isLoading: false })
|
set({ documents: data.items, total: data.total, isLoading: false })
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.error?.message || err.message, isLoading: false })
|
set({ error: errorMessage(err), isLoading: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -81,8 +92,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
||||||
isLoading: false
|
isLoading: false
|
||||||
}))
|
}))
|
||||||
return newDoc
|
return newDoc
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.error?.message || err.message, isLoading: false })
|
set({ error: errorMessage(err), isLoading: false })
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -97,8 +108,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
||||||
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
|
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
|
||||||
isLoading: false
|
isLoading: false
|
||||||
}))
|
}))
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.error?.message || err.message, isLoading: false })
|
set({ error: errorMessage(err), isLoading: false })
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -113,8 +124,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
||||||
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
|
selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)),
|
||||||
isLoading: false
|
isLoading: false
|
||||||
}))
|
}))
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.error?.message || err.message, isLoading: false })
|
set({ error: errorMessage(err), isLoading: false })
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -138,8 +149,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
||||||
selectedIds: new Set(),
|
selectedIds: new Set(),
|
||||||
isLoading: false
|
isLoading: false
|
||||||
}))
|
}))
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.error?.message || err.message, isLoading: false })
|
set({ error: errorMessage(err), isLoading: false })
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -157,8 +168,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
||||||
selectedIds: new Set(),
|
selectedIds: new Set(),
|
||||||
isLoading: false
|
isLoading: false
|
||||||
}))
|
}))
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.error?.message || err.message, isLoading: false })
|
set({ error: errorMessage(err), isLoading: false })
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -173,8 +184,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
||||||
selectedIds: new Set(),
|
selectedIds: new Set(),
|
||||||
isLoading: false
|
isLoading: false
|
||||||
})
|
})
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.error?.message || err.message, isLoading: false })
|
set({ error: errorMessage(err), isLoading: false })
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -187,8 +198,8 @@ export const useLibrary = create<LibraryState>((set, get) => ({
|
||||||
documents: state.documents.map(d => d.id === id ? updatedDoc : d),
|
documents: state.documents.map(d => d.id === id ? updatedDoc : d),
|
||||||
isLoading: false
|
isLoading: false
|
||||||
}))
|
}))
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.error?.message || err.message, isLoading: false })
|
set({ error: errorMessage(err), isLoading: false })
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@
|
||||||
@import "@fontsource/plus-jakarta-sans/500.css";
|
@import "@fontsource/plus-jakarta-sans/500.css";
|
||||||
@import "@fontsource/plus-jakarta-sans/600.css";
|
@import "@fontsource/plus-jakarta-sans/600.css";
|
||||||
@import "@fontsource/plus-jakarta-sans/700.css";
|
@import "@fontsource/plus-jakarta-sans/700.css";
|
||||||
|
@import "@fontsource/allura/400.css";
|
||||||
|
@import "@fontsource/caveat/400.css";
|
||||||
|
@import "@fontsource/dancing-script/400.css";
|
||||||
|
@import "@fontsource/great-vibes/400.css";
|
||||||
|
@import "@fontsource/sacramento/400.css";
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,19 @@ export interface ToolHandler {
|
||||||
onDeactivate?: (canvas: Canvas) => void;
|
onDeactivate?: (canvas: Canvas) => void;
|
||||||
|
|
||||||
/** Called when the user presses down on the canvas. */
|
/** Called when the user presses down on the canvas. */
|
||||||
onPointerDown?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void;
|
onPointerDown?: (e: fabric.TPointerEventInfo, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void | Promise<void>;
|
||||||
|
|
||||||
/** Called when the user drags the pointer. */
|
/** Called when the user drags the pointer. */
|
||||||
onPointerMove?: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void;
|
onPointerMove?: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void | Promise<void>;
|
||||||
onPointerUp?: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void;
|
onPointerUp?: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void | Promise<void>;
|
||||||
onPathCreated?: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void;
|
onPathCreated?: (e: fabric.CanvasEvents['path:created'], canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void | Promise<void>;
|
||||||
|
|
||||||
// Fabric to/from Annotation serialization
|
// Fabric to/from Annotation serialization
|
||||||
renderToFabric?: (annotation: Annotation, canvas: Canvas, viewportParams: ViewportParams) => void;
|
renderToFabric?: (
|
||||||
|
annotation: Annotation,
|
||||||
|
canvas: Canvas,
|
||||||
|
viewportParams: ViewportParams,
|
||||||
|
) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = new Map<string, ToolHandler>();
|
const registry = new Map<string, ToolHandler>();
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,119 @@
|
||||||
import type { components } from '../../types/api';
|
/**
|
||||||
|
* Client annotation model.
|
||||||
|
*
|
||||||
|
* The API intentionally exposes the working layer as an opaque JSON array so
|
||||||
|
* newer annotation types can round-trip through an older server. These types
|
||||||
|
* describe the v1 tools used by this client; the generated API types still
|
||||||
|
* describe the transport envelope and endpoint shapes.
|
||||||
|
*/
|
||||||
|
|
||||||
export type TextProps = components['schemas']['TextProps'] & { highlightColor?: string | null, styles?: Record<string, any> | null };
|
export interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type TextAnnotation = Omit<components['schemas']['TextAnnotation'], 'props'> & {
|
export interface AnnotationBase {
|
||||||
|
id: string;
|
||||||
|
page: number;
|
||||||
|
type: string;
|
||||||
|
rect: Rect;
|
||||||
|
rotation?: number;
|
||||||
|
z?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextProps {
|
||||||
|
text: string;
|
||||||
|
fontFamily: string;
|
||||||
|
fontSize: number;
|
||||||
|
color: string;
|
||||||
|
align: 'left' | 'center' | 'right';
|
||||||
|
bold: boolean;
|
||||||
|
italic: boolean;
|
||||||
|
lineHeight: number;
|
||||||
|
highlightColor?: string | null;
|
||||||
|
styles?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextAnnotation extends AnnotationBase {
|
||||||
|
type: 'text';
|
||||||
props: TextProps;
|
props: TextProps;
|
||||||
};
|
}
|
||||||
export type DrawAnnotation = components['schemas']['DrawAnnotation'];
|
|
||||||
export type SignatureAnnotation = components['schemas']['SignatureAnnotation'];
|
export interface DrawProps {
|
||||||
export type ImageAnnotation = components['schemas']['ImageAnnotation'];
|
paths: [number, number][];
|
||||||
export type HighlightAnnotation = components['schemas']['HighlightAnnotation'];
|
svgPath?: string;
|
||||||
export type ShapeAnnotation = components['schemas']['ShapeAnnotation'];
|
strokeColor: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrawAnnotation extends AnnotationBase {
|
||||||
|
type: 'draw';
|
||||||
|
props: DrawProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureDrawProps {
|
||||||
|
mode: 'draw';
|
||||||
|
ref: string;
|
||||||
|
strokeColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureTypeProps {
|
||||||
|
mode: 'type';
|
||||||
|
text: string;
|
||||||
|
fontFamily: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureAnnotation extends AnnotationBase {
|
||||||
|
type: 'signature';
|
||||||
|
props: SignatureDrawProps | SignatureTypeProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageProps {
|
||||||
|
ref: string;
|
||||||
|
naturalWidth: number;
|
||||||
|
naturalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageAnnotation extends AnnotationBase {
|
||||||
|
type: 'image';
|
||||||
|
props: ImageProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HighlightProps {
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HighlightAnnotation extends AnnotationBase {
|
||||||
|
type: 'highlight';
|
||||||
|
props: HighlightProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShapeProps {
|
||||||
|
kind: 'rect' | 'ellipse' | 'line' | 'arrow';
|
||||||
|
strokeColor: string;
|
||||||
|
fillColor: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
/** Normalized endpoints, retained for line/arrow direction. */
|
||||||
|
start?: [number, number];
|
||||||
|
end?: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShapeAnnotation extends AnnotationBase {
|
||||||
|
type: 'shape';
|
||||||
|
props: ShapeProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnknownAnnotation extends AnnotationBase {
|
||||||
|
type: string;
|
||||||
|
props: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export type Annotation =
|
export type Annotation =
|
||||||
| TextAnnotation
|
| TextAnnotation
|
||||||
|
|
@ -17,12 +121,5 @@ export type Annotation =
|
||||||
| SignatureAnnotation
|
| SignatureAnnotation
|
||||||
| ImageAnnotation
|
| ImageAnnotation
|
||||||
| HighlightAnnotation
|
| HighlightAnnotation
|
||||||
| ShapeAnnotation;
|
| ShapeAnnotation
|
||||||
|
| UnknownAnnotation;
|
||||||
export type Rect = components['schemas']['Rect'];
|
|
||||||
export type DrawProps = components['schemas']['DrawProps'] & { svgPath?: string };
|
|
||||||
export type SignatureDrawProps = components['schemas']['SignatureDrawProps'];
|
|
||||||
export type SignatureTypeProps = components['schemas']['SignatureTypeProps'];
|
|
||||||
export type ImageProps = components['schemas']['ImageProps'];
|
|
||||||
export type HighlightProps = components['schemas']['HighlightProps'];
|
|
||||||
export type ShapeProps = components['schemas']['ShapeProps'];
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,21 @@ export class ApiRequestError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiError(data: unknown, statusText: string): ApiError {
|
||||||
|
const record = isRecord(data) ? data : {};
|
||||||
|
const nested = isRecord(record.error) ? record.error : {};
|
||||||
|
return {
|
||||||
|
code: typeof nested.code === 'string' ? nested.code : 'unknown',
|
||||||
|
message: typeof nested.message === 'string'
|
||||||
|
? nested.message
|
||||||
|
: typeof record.detail === 'string' ? record.detail : statusText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal fetch wrapper with shared behavior.
|
* Internal fetch wrapper with shared behavior.
|
||||||
*/
|
*/
|
||||||
|
|
@ -75,23 +90,19 @@ async function apiFetch<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse response
|
// Parse response
|
||||||
let data: any = {}
|
let data: unknown = {}
|
||||||
try {
|
try {
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
if (text) {
|
if (text) {
|
||||||
data = JSON.parse(text)
|
data = JSON.parse(text)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
// If it's not JSON, we'll just fall back to the empty object
|
// If it's not JSON, we'll just fall back to the empty object
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle error responses
|
// Handle error responses
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error: ApiError = {
|
throw new ApiRequestError(response.status, toApiError(data, response.statusText))
|
||||||
code: data.error?.code ?? 'unknown',
|
|
||||||
message: data.error?.message ?? data.detail ?? response.statusText,
|
|
||||||
}
|
|
||||||
throw new ApiRequestError(response.status, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data as T
|
return data as T
|
||||||
|
|
@ -158,17 +169,15 @@ export const api = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let data: any = {}
|
let data: unknown = {}
|
||||||
try {
|
try {
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
if (text) data = JSON.parse(text)
|
if (text) data = JSON.parse(text)
|
||||||
} catch (err) {}
|
} catch {
|
||||||
|
data = {}
|
||||||
const error: ApiError = {
|
|
||||||
code: data.error?.code ?? 'unknown',
|
|
||||||
message: data.error?.message ?? data.detail ?? response.statusText,
|
|
||||||
}
|
}
|
||||||
throw new ApiRequestError(response.status, error)
|
|
||||||
|
throw new ApiRequestError(response.status, toApiError(data, response.statusText))
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.blob()
|
return response.blob()
|
||||||
|
|
|
||||||
|
|
@ -92,4 +92,16 @@ describe('Coordinate Transforms', () => {
|
||||||
expect(blS.x).toBeCloseTo(0, 2);
|
expect(blS.x).toBeCloseTo(0, 2);
|
||||||
expect(blS.y).toBeCloseTo(0, 2);
|
expect(blS.y).toBeCloseTo(0, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps CSS geometry independent from device pixel ratio', () => {
|
||||||
|
const standard = pdfToScreen(
|
||||||
|
{ x: 120, y: 180 },
|
||||||
|
{ scale: 1.5, rotation: 0, canonicalWidth: W, canonicalHeight: H, dpr: 1 },
|
||||||
|
);
|
||||||
|
const retina = pdfToScreen(
|
||||||
|
{ x: 120, y: 180 },
|
||||||
|
{ scale: 1.5, rotation: 0, canonicalWidth: W, canonicalHeight: H, dpr: 2 },
|
||||||
|
);
|
||||||
|
expect(retina).toEqual(standard);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,18 @@ export interface PdfRect { x: number; y: number; width: number; height: number }
|
||||||
export interface ScreenRect { x: number; y: number; width: number; height: number }
|
export interface ScreenRect { x: number; y: number; width: number; height: number }
|
||||||
|
|
||||||
export interface ViewportParams {
|
export interface ViewportParams {
|
||||||
scale: number; // CSS Zoom level (1.0 = 100%)
|
scale: number; // CSS zoom level (1.0 = 100%)
|
||||||
rotation: number; // 0, 90, 180, 270 (clockwise)
|
rotation: number; // 0, 90, 180, 270 (clockwise)
|
||||||
canonicalWidth: number; // Unrotated CropBox width in PDF points
|
canonicalWidth: number; // Unrotated CropBox width in PDF points
|
||||||
canonicalHeight: number;// Unrotated CropBox height in PDF points
|
canonicalHeight: number;// Unrotated CropBox height in PDF points
|
||||||
dpr?: number; // Device Pixel Ratio (defaults to 1)
|
dpr?: number; // Backing-store density; never part of CSS geometry
|
||||||
}
|
|
||||||
|
|
||||||
function getEffectiveScale(vp: ViewportParams): number {
|
|
||||||
return vp.scale * (vp.dpr || 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
|
export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
|
||||||
const s = getEffectiveScale(vp);
|
// Screen points are CSS pixels. Device-pixel-ratio only controls the PDF.js
|
||||||
|
// backing canvas; including it here would make Fabric objects overflow the
|
||||||
|
// CSS overlay on retina displays.
|
||||||
|
const s = vp.scale;
|
||||||
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
|
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
|
||||||
const { x, y } = p;
|
const { x, y } = p;
|
||||||
|
|
||||||
|
|
@ -33,7 +32,7 @@ export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function screenToPdf(p: ScreenPoint, vp: ViewportParams): PdfPoint {
|
export function screenToPdf(p: ScreenPoint, vp: ViewportParams): PdfPoint {
|
||||||
const s = getEffectiveScale(vp);
|
const s = vp.scale;
|
||||||
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
|
const { rotation, canonicalWidth: W, canonicalHeight: H } = vp;
|
||||||
const sx = p.x / s;
|
const sx = p.x / s;
|
||||||
const sy = p.y / s;
|
const sy = p.y / s;
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,113 @@
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useEffect } from 'react'
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { PdfDocument } from '../features/editor/pages/PdfDocument'
|
import { api } from '../lib/api/client';
|
||||||
import { EditorToolbar } from '../features/editor/toolbar/EditorToolbar'
|
import { PdfDocument } from '../features/editor/pages/PdfDocument';
|
||||||
import { useEditorStore } from '../features/editor/store'
|
import { EditorToolbar } from '../features/editor/toolbar/EditorToolbar';
|
||||||
import { useAutosave } from '../features/editor/useAutosave'
|
import { VersionPanel } from '../features/editor/versions/VersionPanel';
|
||||||
import '../features/editor/tools'
|
import { useEditorStore } from '../features/editor/store';
|
||||||
|
import { useAutosave } from '../features/editor/useAutosave';
|
||||||
|
import '../features/editor/tools';
|
||||||
|
|
||||||
|
interface DocumentSummary {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Editor page — PDF canvas workspace.
|
|
||||||
*/
|
|
||||||
export function EditorPage() {
|
export function EditorPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>();
|
||||||
const { setDocumentId } = useEditorStore()
|
const [title, setTitle] = useState('Untitled document');
|
||||||
useAutosave()
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
|
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||||
|
const undo = useEditorStore((state) => state.undo);
|
||||||
|
const redo = useEditorStore((state) => state.redo);
|
||||||
|
|
||||||
|
const { flush } = useAutosave(id);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (!id) return;
|
||||||
setDocumentId(id)
|
let active = true;
|
||||||
}
|
void api.get<DocumentSummary>(`/documents/${id}`).then((document) => {
|
||||||
}, [id, setDocumentId])
|
if (active) setTitle(document.title);
|
||||||
|
}).catch(() => {
|
||||||
|
if (active) setTitle('Untitled document');
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
if (!id) return <div>Invalid document ID</div>
|
useEffect(() => {
|
||||||
|
const handleHistoryShortcut = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.shiftKey) redo();
|
||||||
|
else undo();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleHistoryShortcut);
|
||||||
|
return () => document.removeEventListener('keydown', handleHistoryShortcut);
|
||||||
|
}, [redo, undo]);
|
||||||
|
|
||||||
|
const handleExport = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setIsExporting(true);
|
||||||
|
setExportError(null);
|
||||||
|
try {
|
||||||
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||||
|
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
const saved = await flush();
|
||||||
|
if (!saved && useEditorStore.getState().saveStatus === 'loading') {
|
||||||
|
throw new Error('Please wait for the annotation layer to finish loading.');
|
||||||
|
}
|
||||||
|
const blob = await api.fetchBlob(`/documents/${id}/export`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ flatten: true }),
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = title.toLowerCase().endsWith('.pdf') ? title : `${title}.pdf`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch (reason) {
|
||||||
|
const message = reason instanceof Error ? reason.message : 'Unable to export this PDF.';
|
||||||
|
setExportError(message);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
}, [flush, id, title]);
|
||||||
|
|
||||||
|
if (!id) return <div className="p-8 text-center text-red-600">Invalid document ID.</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-neutral-100 overflow-hidden">
|
<div className="flex h-screen flex-col overflow-hidden bg-neutral-100">
|
||||||
{/* Editor toolbar */}
|
<header className="z-40 flex flex-none items-center justify-between border-b border-neutral-200 bg-white px-4 py-2.5 shadow-sm">
|
||||||
<header className="flex-none flex items-center justify-between border-b border-neutral-200 bg-white px-4 py-2 z-10 shadow-sm">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<Link to="/" className="rounded-md px-2 py-1.5 text-sm font-semibold text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-800">
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="rounded-md p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-700"
|
|
||||||
>
|
|
||||||
← Back
|
← Back
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-sm font-medium text-neutral-700">
|
<div className="h-5 w-px bg-neutral-200" />
|
||||||
Document {id}
|
<span className="max-w-[min(50vw,32rem)] truncate text-sm font-semibold text-neutral-800" title={title}>{title}</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<span className="flex items-center gap-1.5 text-xs text-neutral-400">
|
{exportError && <span className="max-w-64 truncate text-xs font-medium text-red-600" title={exportError}>{exportError}</span>}
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
<button type="button" onClick={() => void handleExport()} disabled={isExporting} className="rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-bold text-white transition-colors hover:bg-accent-700 disabled:cursor-wait disabled:opacity-60">
|
||||||
Saved
|
{isExporting ? 'Exporting…' : 'Export PDF'}
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
|
|
||||||
>
|
|
||||||
Export PDF
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main workspace area */}
|
<main className="relative flex-1 overflow-auto">
|
||||||
<div className="flex-1 overflow-auto relative">
|
<EditorToolbar onExport={() => void handleExport()} onOpenVersions={() => setIsVersionPanelOpen(true)} isExporting={isExporting} />
|
||||||
<EditorToolbar />
|
|
||||||
<PdfDocument documentId={id} />
|
<PdfDocument documentId={id} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{isVersionPanelOpen && <VersionPanel documentId={id} onClose={() => setIsVersionPanelOpen(false)} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { LibraryHeader } from '../features/library/LibraryHeader'
|
import { LibraryHeader } from '../features/library/LibraryHeader'
|
||||||
import { UploadArea } from '../features/library/UploadArea'
|
import { UploadArea, UploadTrigger } from '../features/library/UploadArea'
|
||||||
import { DocumentGrid } from '../features/library/DocumentGrid'
|
import { DocumentGrid } from '../features/library/DocumentGrid'
|
||||||
import { useLibrary } from '../features/library/useLibrary'
|
import { useLibrary } from '../features/library/useLibrary'
|
||||||
|
|
||||||
|
|
@ -38,26 +38,12 @@ export function HomePage() {
|
||||||
<p className="text-base sm:text-lg text-accent-100 font-medium mb-6 leading-relaxed">
|
<p className="text-base sm:text-lg text-accent-100 font-medium mb-6 leading-relaxed">
|
||||||
Upload any PDF to instantly annotate, sign, and modify it. Drop your files right here to get started.
|
Upload any PDF to instantly annotate, sign, and modify it. Drop your files right here to get started.
|
||||||
</p>
|
</p>
|
||||||
<label className="cursor-pointer inline-flex items-center justify-center px-6 py-3 text-base font-bold rounded-full text-accent-900 bg-white hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-white/30 transition-all shadow-xl hover:-translate-y-1 active:translate-y-0">
|
<UploadTrigger className="cursor-pointer inline-flex items-center justify-center px-6 py-3 text-base font-bold rounded-full text-accent-900 bg-white hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-white/30 transition-all shadow-xl hover:-translate-y-1 active:translate-y-0">
|
||||||
<svg className="w-5 h-5 mr-2 text-accent-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-5 h-5 mr-2 text-accent-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Browse Files</span>
|
<span>Browse Files</span>
|
||||||
<input
|
</UploadTrigger>
|
||||||
type="file"
|
|
||||||
accept="application/pdf"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const uploader = document.querySelector('input[type="file"][multiple]') as HTMLInputElement
|
|
||||||
if (uploader && uploader !== e.target) {
|
|
||||||
uploader.files = e.target.files
|
|
||||||
const event = new Event('change', { bubbles: true })
|
|
||||||
uploader.dispatchEvent(event)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Visual Graphic */}
|
{/* Visual Graphic */}
|
||||||
|
|
|
||||||
848
frontend/src/types/api.d.ts
vendored
848
frontend/src/types/api.d.ts
vendored
|
|
@ -272,6 +272,46 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/documents/{id}/assets": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Upload Asset
|
||||||
|
* @description Upload a PNG, JPEG, GIF, or WebP annotation asset.
|
||||||
|
*/
|
||||||
|
post: operations["upload_asset_api_v1_documents__id__assets_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/documents/{id}/assets/{ref}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Asset
|
||||||
|
* @description Serve an annotation asset to an authenticated session.
|
||||||
|
*/
|
||||||
|
get: operations["get_asset_api_v1_documents__id__assets__ref__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/documents/{document_id}/annotations": {
|
"/api/v1/documents/{document_id}/annotations": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -290,6 +330,79 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/documents/{document_id}/versions": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Versions */
|
||||||
|
get: operations["list_versions_api_v1_documents__document_id__versions_get"];
|
||||||
|
put?: never;
|
||||||
|
/** Create Version */
|
||||||
|
post: operations["create_version_api_v1_documents__document_id__versions_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/documents/{document_id}/versions/{version_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get Version */
|
||||||
|
get: operations["get_version_api_v1_documents__document_id__versions__version_id__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Version */
|
||||||
|
delete: operations["delete_version_api_v1_documents__document_id__versions__version_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/documents/{document_id}/versions/{version_id}/restore": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Restore Version */
|
||||||
|
post: operations["restore_version_api_v1_documents__document_id__versions__version_id__restore_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/documents/{document_id}/export": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Export Document
|
||||||
|
* @description Export the working layer or a selected version as a downloadable PDF.
|
||||||
|
*/
|
||||||
|
post: operations["export_document_api_v1_documents__document_id__export_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/health": {
|
"/api/v1/health": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -310,28 +423,6 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/v1/debug/verify-coords": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/**
|
|
||||||
* Verify Coords
|
|
||||||
* @description Test endpoint for cross-engine coordinate verification.
|
|
||||||
* Takes a canonical rect, draws it on the PDF using PyMuPDF,
|
|
||||||
* and returns the flattened PDF.
|
|
||||||
*/
|
|
||||||
post: operations["verify_coords_api_v1_debug_verify_coords_post"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
|
|
@ -339,7 +430,9 @@ export interface components {
|
||||||
/** AnnotationStateResponse */
|
/** AnnotationStateResponse */
|
||||||
AnnotationStateResponse: {
|
AnnotationStateResponse: {
|
||||||
/** Data */
|
/** Data */
|
||||||
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
|
data: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}[];
|
||||||
/**
|
/**
|
||||||
* Updatedat
|
* Updatedat
|
||||||
* Format: date-time
|
* Format: date-time
|
||||||
|
|
@ -349,7 +442,9 @@ export interface components {
|
||||||
/** AnnotationStateUpdateRequest */
|
/** AnnotationStateUpdateRequest */
|
||||||
AnnotationStateUpdateRequest: {
|
AnnotationStateUpdateRequest: {
|
||||||
/** Data */
|
/** Data */
|
||||||
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
|
data: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}[];
|
||||||
/** Baseupdatedat */
|
/** Baseupdatedat */
|
||||||
baseUpdatedAt?: string | null;
|
baseUpdatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -368,6 +463,11 @@ export interface components {
|
||||||
/** Loggedin */
|
/** Loggedin */
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
};
|
};
|
||||||
|
/** Body_upload_asset_api_v1_documents__id__assets_post */
|
||||||
|
Body_upload_asset_api_v1_documents__id__assets_post: {
|
||||||
|
/** File */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
/** Body_upload_document_api_v1_documents_post */
|
/** Body_upload_document_api_v1_documents_post */
|
||||||
Body_upload_document_api_v1_documents_post: {
|
Body_upload_document_api_v1_documents_post: {
|
||||||
/** File */
|
/** File */
|
||||||
|
|
@ -425,402 +525,31 @@ export interface components {
|
||||||
/** In Trash */
|
/** In Trash */
|
||||||
in_trash?: boolean | null;
|
in_trash?: boolean | null;
|
||||||
};
|
};
|
||||||
/** DrawAnnotation */
|
/** ExportRequest */
|
||||||
DrawAnnotation: {
|
ExportRequest: {
|
||||||
|
/** Versionid */
|
||||||
|
versionId?: string | null;
|
||||||
/**
|
/**
|
||||||
* Id
|
* Flatten
|
||||||
* Format: uuid
|
* @default true
|
||||||
*/
|
*/
|
||||||
id: string;
|
flatten: boolean;
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "draw";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
props: components["schemas"]["DrawProps"];
|
|
||||||
};
|
|
||||||
/** DrawProps */
|
|
||||||
DrawProps: {
|
|
||||||
/** Paths */
|
|
||||||
paths: [
|
|
||||||
number,
|
|
||||||
number
|
|
||||||
][];
|
|
||||||
/**
|
|
||||||
* Strokecolor
|
|
||||||
* @default #000000
|
|
||||||
*/
|
|
||||||
strokeColor: string;
|
|
||||||
/**
|
|
||||||
* Strokewidth
|
|
||||||
* @default 2
|
|
||||||
*/
|
|
||||||
strokeWidth: number;
|
|
||||||
/**
|
|
||||||
* Opacity
|
|
||||||
* @default 1
|
|
||||||
*/
|
|
||||||
opacity: number;
|
|
||||||
};
|
};
|
||||||
/** HTTPValidationError */
|
/** HTTPValidationError */
|
||||||
HTTPValidationError: {
|
HTTPValidationError: {
|
||||||
/** Detail */
|
/** Detail */
|
||||||
detail?: components["schemas"]["ValidationError"][];
|
detail?: components["schemas"]["ValidationError"][];
|
||||||
};
|
};
|
||||||
/** HighlightAnnotation */
|
|
||||||
HighlightAnnotation: {
|
|
||||||
/**
|
|
||||||
* Id
|
|
||||||
* Format: uuid
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "highlight";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
props: components["schemas"]["HighlightProps"];
|
|
||||||
};
|
|
||||||
/** HighlightProps */
|
|
||||||
HighlightProps: {
|
|
||||||
/**
|
|
||||||
* Color
|
|
||||||
* @default #FFEB3B
|
|
||||||
*/
|
|
||||||
color: string;
|
|
||||||
/**
|
|
||||||
* Opacity
|
|
||||||
* @default 0.3
|
|
||||||
*/
|
|
||||||
opacity: number;
|
|
||||||
};
|
|
||||||
/** ImageAnnotation */
|
|
||||||
ImageAnnotation: {
|
|
||||||
/**
|
|
||||||
* Id
|
|
||||||
* Format: uuid
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "image";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
props: components["schemas"]["ImageProps"];
|
|
||||||
};
|
|
||||||
/** ImageProps */
|
|
||||||
ImageProps: {
|
|
||||||
/** Ref */
|
|
||||||
ref: string;
|
|
||||||
/** Naturalwidth */
|
|
||||||
naturalWidth: number;
|
|
||||||
/** Naturalheight */
|
|
||||||
naturalHeight: number;
|
|
||||||
};
|
|
||||||
/** LoginRequest */
|
/** LoginRequest */
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
/** Password */
|
/** Password */
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
/** Rect */
|
|
||||||
Rect: {
|
|
||||||
/** X */
|
|
||||||
x: number;
|
|
||||||
/** Y */
|
|
||||||
y: number;
|
|
||||||
/** Width */
|
|
||||||
width: number;
|
|
||||||
/** Height */
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
/** SetupRequest */
|
/** SetupRequest */
|
||||||
SetupRequest: {
|
SetupRequest: {
|
||||||
/** Password */
|
/** Password */
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
/** ShapeAnnotation */
|
|
||||||
ShapeAnnotation: {
|
|
||||||
/**
|
|
||||||
* Id
|
|
||||||
* Format: uuid
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "shape";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
props: components["schemas"]["ShapeProps"];
|
|
||||||
};
|
|
||||||
/** ShapeProps */
|
|
||||||
ShapeProps: {
|
|
||||||
/**
|
|
||||||
* Kind
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
kind: "rect" | "ellipse" | "line" | "arrow";
|
|
||||||
/**
|
|
||||||
* Strokecolor
|
|
||||||
* @default #000000
|
|
||||||
*/
|
|
||||||
strokeColor: string;
|
|
||||||
/**
|
|
||||||
* Fillcolor
|
|
||||||
* @default transparent
|
|
||||||
*/
|
|
||||||
fillColor: string;
|
|
||||||
/**
|
|
||||||
* Strokewidth
|
|
||||||
* @default 2
|
|
||||||
*/
|
|
||||||
strokeWidth: number;
|
|
||||||
};
|
|
||||||
/** SignatureAnnotation */
|
|
||||||
SignatureAnnotation: {
|
|
||||||
/**
|
|
||||||
* Id
|
|
||||||
* Format: uuid
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "signature";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
/** Props */
|
|
||||||
props: components["schemas"]["SignatureDrawProps"] | components["schemas"]["SignatureTypeProps"];
|
|
||||||
};
|
|
||||||
/** SignatureDrawProps */
|
|
||||||
SignatureDrawProps: {
|
|
||||||
/**
|
|
||||||
* @description discriminator enum property added by openapi-typescript
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
mode: "draw";
|
|
||||||
/** Ref */
|
|
||||||
ref: string;
|
|
||||||
/**
|
|
||||||
* Strokecolor
|
|
||||||
* @default #000000
|
|
||||||
*/
|
|
||||||
strokeColor: string;
|
|
||||||
};
|
|
||||||
/** SignatureTypeProps */
|
|
||||||
SignatureTypeProps: {
|
|
||||||
/**
|
|
||||||
* @description discriminator enum property added by openapi-typescript
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
mode: "type";
|
|
||||||
/** Text */
|
|
||||||
text: string;
|
|
||||||
/** Fontfamily */
|
|
||||||
fontFamily: string;
|
|
||||||
/**
|
|
||||||
* Color
|
|
||||||
* @default #000000
|
|
||||||
*/
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
/** TextAnnotation */
|
|
||||||
TextAnnotation: {
|
|
||||||
/**
|
|
||||||
* Id
|
|
||||||
* Format: uuid
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "text";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
props: components["schemas"]["TextProps"];
|
|
||||||
};
|
|
||||||
/** TextProps */
|
|
||||||
TextProps: {
|
|
||||||
/** Text */
|
|
||||||
text: string;
|
|
||||||
/**
|
|
||||||
* Fontfamily
|
|
||||||
* @default Liberation Sans
|
|
||||||
*/
|
|
||||||
fontFamily: string;
|
|
||||||
/**
|
|
||||||
* Fontsize
|
|
||||||
* @default 14
|
|
||||||
*/
|
|
||||||
fontSize: number;
|
|
||||||
/**
|
|
||||||
* Color
|
|
||||||
* @default #000000
|
|
||||||
*/
|
|
||||||
color: string;
|
|
||||||
/**
|
|
||||||
* Align
|
|
||||||
* @default left
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
align: "left" | "center" | "right";
|
|
||||||
/**
|
|
||||||
* Bold
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
bold: boolean;
|
|
||||||
/**
|
|
||||||
* Italic
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
italic: boolean;
|
|
||||||
/**
|
|
||||||
* Lineheight
|
|
||||||
* @default 1.2
|
|
||||||
*/
|
|
||||||
lineHeight: number;
|
|
||||||
/** Highlightcolor */
|
|
||||||
highlightColor?: string | null;
|
|
||||||
/** Styles */
|
|
||||||
styles?: {
|
|
||||||
[key: string]: unknown;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
/** ValidationError */
|
/** ValidationError */
|
||||||
ValidationError: {
|
ValidationError: {
|
||||||
/** Location */
|
/** Location */
|
||||||
|
|
@ -834,20 +563,58 @@ export interface components {
|
||||||
/** Context */
|
/** Context */
|
||||||
ctx?: Record<string, never>;
|
ctx?: Record<string, never>;
|
||||||
};
|
};
|
||||||
/** VerifyCoordsRequest */
|
/** VersionCreateRequest */
|
||||||
VerifyCoordsRequest: {
|
VersionCreateRequest: {
|
||||||
/** Document Id */
|
/** Label */
|
||||||
document_id: string;
|
label?: string | null;
|
||||||
/** Page */
|
/**
|
||||||
page: number;
|
* Kind
|
||||||
/** X */
|
* @default manual
|
||||||
x: number;
|
* @enum {string}
|
||||||
/** Y */
|
*/
|
||||||
y: number;
|
kind: "manual" | "auto";
|
||||||
/** Width */
|
};
|
||||||
width: number;
|
/** VersionDataResponse */
|
||||||
/** Height */
|
VersionDataResponse: {
|
||||||
height: number;
|
/** Data */
|
||||||
|
data: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}[];
|
||||||
|
meta: components["schemas"]["VersionMeta"];
|
||||||
|
};
|
||||||
|
/** VersionListResponse */
|
||||||
|
VersionListResponse: {
|
||||||
|
/** Items */
|
||||||
|
items: components["schemas"]["VersionMeta"][];
|
||||||
|
};
|
||||||
|
/** VersionMeta */
|
||||||
|
VersionMeta: {
|
||||||
|
/** Id */
|
||||||
|
id: string;
|
||||||
|
/** Documentid */
|
||||||
|
documentId: string;
|
||||||
|
/** Label */
|
||||||
|
label: string | null;
|
||||||
|
/**
|
||||||
|
* Kind
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
kind: "manual" | "auto";
|
||||||
|
/**
|
||||||
|
* Createdat
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
createdAt: string;
|
||||||
|
/** Annotationcount */
|
||||||
|
annotationCount: number;
|
||||||
|
};
|
||||||
|
/** VersionRestoreResponse */
|
||||||
|
VersionRestoreResponse: {
|
||||||
|
/**
|
||||||
|
* Updatedat
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
|
|
@ -1052,7 +819,7 @@ export interface operations {
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Successful Response */
|
/** @description Successful Response */
|
||||||
200: {
|
201: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
@ -1381,6 +1148,75 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
upload_asset_api_v1_documents__id__assets_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["Body_upload_asset_api_v1_documents__id__assets_post"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_asset_api_v1_documents__id__assets__ref__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
ref: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
get_annotations_api_v1_documents__document_id__annotations_get: {
|
get_annotations_api_v1_documents__document_id__annotations_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -1447,11 +1283,13 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
health_check_api_v1_health_get: {
|
list_versions_api_v1_documents__document_id__versions_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
|
|
@ -1462,23 +1300,161 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": components["schemas"]["VersionListResponse"];
|
||||||
[key: string]: string;
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
create_version_api_v1_documents__document_id__versions_post: {
|
||||||
verify_coords_api_v1_debug_verify_coords_post: {
|
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["VerifyCoordsRequest"];
|
"application/json": components["schemas"]["VersionCreateRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["VersionMeta"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_version_api_v1_documents__document_id__versions__version_id__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["VersionDataResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_version_api_v1_documents__document_id__versions__version_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
restore_version_api_v1_documents__document_id__versions__version_id__restore_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["VersionRestoreResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export_document_api_v1_documents__document_id__export_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ExportRequest"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
|
|
@ -1502,4 +1478,26 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
health_check_api_v1_health_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,46 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/documents/{id}/assets": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Upload Asset
|
||||||
|
* @description Upload a PNG, JPEG, GIF, or WebP annotation asset.
|
||||||
|
*/
|
||||||
|
post: operations["upload_asset_api_v1_documents__id__assets_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/documents/{id}/assets/{ref}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Asset
|
||||||
|
* @description Serve an annotation asset to an authenticated session.
|
||||||
|
*/
|
||||||
|
get: operations["get_asset_api_v1_documents__id__assets__ref__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/documents/{document_id}/annotations": {
|
"/api/v1/documents/{document_id}/annotations": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -290,6 +330,79 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/documents/{document_id}/versions": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Versions */
|
||||||
|
get: operations["list_versions_api_v1_documents__document_id__versions_get"];
|
||||||
|
put?: never;
|
||||||
|
/** Create Version */
|
||||||
|
post: operations["create_version_api_v1_documents__document_id__versions_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/documents/{document_id}/versions/{version_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get Version */
|
||||||
|
get: operations["get_version_api_v1_documents__document_id__versions__version_id__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Version */
|
||||||
|
delete: operations["delete_version_api_v1_documents__document_id__versions__version_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/documents/{document_id}/versions/{version_id}/restore": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Restore Version */
|
||||||
|
post: operations["restore_version_api_v1_documents__document_id__versions__version_id__restore_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/documents/{document_id}/export": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Export Document
|
||||||
|
* @description Export the working layer or a selected version as a downloadable PDF.
|
||||||
|
*/
|
||||||
|
post: operations["export_document_api_v1_documents__document_id__export_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/health": {
|
"/api/v1/health": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -310,28 +423,6 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/v1/debug/verify-coords": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/**
|
|
||||||
* Verify Coords
|
|
||||||
* @description Test endpoint for cross-engine coordinate verification.
|
|
||||||
* Takes a canonical rect, draws it on the PDF using PyMuPDF,
|
|
||||||
* and returns the flattened PDF.
|
|
||||||
*/
|
|
||||||
post: operations["verify_coords_api_v1_debug_verify_coords_post"];
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
|
|
@ -339,7 +430,9 @@ export interface components {
|
||||||
/** AnnotationStateResponse */
|
/** AnnotationStateResponse */
|
||||||
AnnotationStateResponse: {
|
AnnotationStateResponse: {
|
||||||
/** Data */
|
/** Data */
|
||||||
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
|
data: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}[];
|
||||||
/**
|
/**
|
||||||
* Updatedat
|
* Updatedat
|
||||||
* Format: date-time
|
* Format: date-time
|
||||||
|
|
@ -349,7 +442,9 @@ export interface components {
|
||||||
/** AnnotationStateUpdateRequest */
|
/** AnnotationStateUpdateRequest */
|
||||||
AnnotationStateUpdateRequest: {
|
AnnotationStateUpdateRequest: {
|
||||||
/** Data */
|
/** Data */
|
||||||
data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[];
|
data: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}[];
|
||||||
/** Baseupdatedat */
|
/** Baseupdatedat */
|
||||||
baseUpdatedAt?: string | null;
|
baseUpdatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -368,6 +463,11 @@ export interface components {
|
||||||
/** Loggedin */
|
/** Loggedin */
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
};
|
};
|
||||||
|
/** Body_upload_asset_api_v1_documents__id__assets_post */
|
||||||
|
Body_upload_asset_api_v1_documents__id__assets_post: {
|
||||||
|
/** File */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
/** Body_upload_document_api_v1_documents_post */
|
/** Body_upload_document_api_v1_documents_post */
|
||||||
Body_upload_document_api_v1_documents_post: {
|
Body_upload_document_api_v1_documents_post: {
|
||||||
/** File */
|
/** File */
|
||||||
|
|
@ -425,396 +525,31 @@ export interface components {
|
||||||
/** In Trash */
|
/** In Trash */
|
||||||
in_trash?: boolean | null;
|
in_trash?: boolean | null;
|
||||||
};
|
};
|
||||||
/** DrawAnnotation */
|
/** ExportRequest */
|
||||||
DrawAnnotation: {
|
ExportRequest: {
|
||||||
|
/** Versionid */
|
||||||
|
versionId?: string | null;
|
||||||
/**
|
/**
|
||||||
* Id
|
* Flatten
|
||||||
* Format: uuid
|
* @default true
|
||||||
*/
|
*/
|
||||||
id: string;
|
flatten: boolean;
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "draw";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
props: components["schemas"]["DrawProps"];
|
|
||||||
};
|
|
||||||
/** DrawProps */
|
|
||||||
DrawProps: {
|
|
||||||
/** Paths */
|
|
||||||
paths: [
|
|
||||||
number,
|
|
||||||
number
|
|
||||||
][];
|
|
||||||
/**
|
|
||||||
* Strokecolor
|
|
||||||
* @default #000000
|
|
||||||
*/
|
|
||||||
strokeColor: string;
|
|
||||||
/**
|
|
||||||
* Strokewidth
|
|
||||||
* @default 2
|
|
||||||
*/
|
|
||||||
strokeWidth: number;
|
|
||||||
/**
|
|
||||||
* Opacity
|
|
||||||
* @default 1
|
|
||||||
*/
|
|
||||||
opacity: number;
|
|
||||||
};
|
};
|
||||||
/** HTTPValidationError */
|
/** HTTPValidationError */
|
||||||
HTTPValidationError: {
|
HTTPValidationError: {
|
||||||
/** Detail */
|
/** Detail */
|
||||||
detail?: components["schemas"]["ValidationError"][];
|
detail?: components["schemas"]["ValidationError"][];
|
||||||
};
|
};
|
||||||
/** HighlightAnnotation */
|
|
||||||
HighlightAnnotation: {
|
|
||||||
/**
|
|
||||||
* Id
|
|
||||||
* Format: uuid
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "highlight";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
props: components["schemas"]["HighlightProps"];
|
|
||||||
};
|
|
||||||
/** HighlightProps */
|
|
||||||
HighlightProps: {
|
|
||||||
/**
|
|
||||||
* Color
|
|
||||||
* @default #FFEB3B
|
|
||||||
*/
|
|
||||||
color: string;
|
|
||||||
/**
|
|
||||||
* Opacity
|
|
||||||
* @default 0.3
|
|
||||||
*/
|
|
||||||
opacity: number;
|
|
||||||
};
|
|
||||||
/** ImageAnnotation */
|
|
||||||
ImageAnnotation: {
|
|
||||||
/**
|
|
||||||
* Id
|
|
||||||
* Format: uuid
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "image";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
props: components["schemas"]["ImageProps"];
|
|
||||||
};
|
|
||||||
/** ImageProps */
|
|
||||||
ImageProps: {
|
|
||||||
/** Ref */
|
|
||||||
ref: string;
|
|
||||||
/** Naturalwidth */
|
|
||||||
naturalWidth: number;
|
|
||||||
/** Naturalheight */
|
|
||||||
naturalHeight: number;
|
|
||||||
};
|
|
||||||
/** LoginRequest */
|
/** LoginRequest */
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
/** Password */
|
/** Password */
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
/** Rect */
|
|
||||||
Rect: {
|
|
||||||
/** X */
|
|
||||||
x: number;
|
|
||||||
/** Y */
|
|
||||||
y: number;
|
|
||||||
/** Width */
|
|
||||||
width: number;
|
|
||||||
/** Height */
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
/** SetupRequest */
|
/** SetupRequest */
|
||||||
SetupRequest: {
|
SetupRequest: {
|
||||||
/** Password */
|
/** Password */
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
/** ShapeAnnotation */
|
|
||||||
ShapeAnnotation: {
|
|
||||||
/**
|
|
||||||
* Id
|
|
||||||
* Format: uuid
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "shape";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
props: components["schemas"]["ShapeProps"];
|
|
||||||
};
|
|
||||||
/** ShapeProps */
|
|
||||||
ShapeProps: {
|
|
||||||
/**
|
|
||||||
* Kind
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
kind: "rect" | "ellipse" | "line" | "arrow";
|
|
||||||
/**
|
|
||||||
* Strokecolor
|
|
||||||
* @default #000000
|
|
||||||
*/
|
|
||||||
strokeColor: string;
|
|
||||||
/**
|
|
||||||
* Fillcolor
|
|
||||||
* @default transparent
|
|
||||||
*/
|
|
||||||
fillColor: string;
|
|
||||||
/**
|
|
||||||
* Strokewidth
|
|
||||||
* @default 2
|
|
||||||
*/
|
|
||||||
strokeWidth: number;
|
|
||||||
};
|
|
||||||
/** SignatureAnnotation */
|
|
||||||
SignatureAnnotation: {
|
|
||||||
/**
|
|
||||||
* Id
|
|
||||||
* Format: uuid
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "signature";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
/** Props */
|
|
||||||
props: components["schemas"]["SignatureDrawProps"] | components["schemas"]["SignatureTypeProps"];
|
|
||||||
};
|
|
||||||
/** SignatureDrawProps */
|
|
||||||
SignatureDrawProps: {
|
|
||||||
/**
|
|
||||||
* @description discriminator enum property added by openapi-typescript
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
mode: "draw";
|
|
||||||
/** Ref */
|
|
||||||
ref: string;
|
|
||||||
/**
|
|
||||||
* Strokecolor
|
|
||||||
* @default #000000
|
|
||||||
*/
|
|
||||||
strokeColor: string;
|
|
||||||
};
|
|
||||||
/** SignatureTypeProps */
|
|
||||||
SignatureTypeProps: {
|
|
||||||
/**
|
|
||||||
* @description discriminator enum property added by openapi-typescript
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
mode: "type";
|
|
||||||
/** Text */
|
|
||||||
text: string;
|
|
||||||
/** Fontfamily */
|
|
||||||
fontFamily: string;
|
|
||||||
/**
|
|
||||||
* Color
|
|
||||||
* @default #000000
|
|
||||||
*/
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
/** TextAnnotation */
|
|
||||||
TextAnnotation: {
|
|
||||||
/**
|
|
||||||
* Id
|
|
||||||
* Format: uuid
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/** Page */
|
|
||||||
page: number;
|
|
||||||
/**
|
|
||||||
* Type
|
|
||||||
* @constant
|
|
||||||
*/
|
|
||||||
type: "text";
|
|
||||||
rect: components["schemas"]["Rect"];
|
|
||||||
/**
|
|
||||||
* Rotation
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
rotation: number;
|
|
||||||
/**
|
|
||||||
* Z
|
|
||||||
* @default 0
|
|
||||||
*/
|
|
||||||
z: number;
|
|
||||||
/**
|
|
||||||
* Createdat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
createdAt: string;
|
|
||||||
/**
|
|
||||||
* Updatedat
|
|
||||||
* Format: date-time
|
|
||||||
*/
|
|
||||||
updatedAt: string;
|
|
||||||
props: components["schemas"]["TextProps"];
|
|
||||||
};
|
|
||||||
/** TextProps */
|
|
||||||
TextProps: {
|
|
||||||
/** Text */
|
|
||||||
text: string;
|
|
||||||
/**
|
|
||||||
* Fontfamily
|
|
||||||
* @default Liberation Sans
|
|
||||||
*/
|
|
||||||
fontFamily: string;
|
|
||||||
/**
|
|
||||||
* Fontsize
|
|
||||||
* @default 14
|
|
||||||
*/
|
|
||||||
fontSize: number;
|
|
||||||
/**
|
|
||||||
* Color
|
|
||||||
* @default #000000
|
|
||||||
*/
|
|
||||||
color: string;
|
|
||||||
/**
|
|
||||||
* Align
|
|
||||||
* @default left
|
|
||||||
* @enum {string}
|
|
||||||
*/
|
|
||||||
align: "left" | "center" | "right";
|
|
||||||
/**
|
|
||||||
* Bold
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
bold: boolean;
|
|
||||||
/**
|
|
||||||
* Italic
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
italic: boolean;
|
|
||||||
/**
|
|
||||||
* Lineheight
|
|
||||||
* @default 1.2
|
|
||||||
*/
|
|
||||||
lineHeight: number;
|
|
||||||
};
|
|
||||||
/** ValidationError */
|
/** ValidationError */
|
||||||
ValidationError: {
|
ValidationError: {
|
||||||
/** Location */
|
/** Location */
|
||||||
|
|
@ -828,20 +563,58 @@ export interface components {
|
||||||
/** Context */
|
/** Context */
|
||||||
ctx?: Record<string, never>;
|
ctx?: Record<string, never>;
|
||||||
};
|
};
|
||||||
/** VerifyCoordsRequest */
|
/** VersionCreateRequest */
|
||||||
VerifyCoordsRequest: {
|
VersionCreateRequest: {
|
||||||
/** Document Id */
|
/** Label */
|
||||||
document_id: string;
|
label?: string | null;
|
||||||
/** Page */
|
/**
|
||||||
page: number;
|
* Kind
|
||||||
/** X */
|
* @default manual
|
||||||
x: number;
|
* @enum {string}
|
||||||
/** Y */
|
*/
|
||||||
y: number;
|
kind: "manual" | "auto";
|
||||||
/** Width */
|
};
|
||||||
width: number;
|
/** VersionDataResponse */
|
||||||
/** Height */
|
VersionDataResponse: {
|
||||||
height: number;
|
/** Data */
|
||||||
|
data: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}[];
|
||||||
|
meta: components["schemas"]["VersionMeta"];
|
||||||
|
};
|
||||||
|
/** VersionListResponse */
|
||||||
|
VersionListResponse: {
|
||||||
|
/** Items */
|
||||||
|
items: components["schemas"]["VersionMeta"][];
|
||||||
|
};
|
||||||
|
/** VersionMeta */
|
||||||
|
VersionMeta: {
|
||||||
|
/** Id */
|
||||||
|
id: string;
|
||||||
|
/** Documentid */
|
||||||
|
documentId: string;
|
||||||
|
/** Label */
|
||||||
|
label: string | null;
|
||||||
|
/**
|
||||||
|
* Kind
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
kind: "manual" | "auto";
|
||||||
|
/**
|
||||||
|
* Createdat
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
createdAt: string;
|
||||||
|
/** Annotationcount */
|
||||||
|
annotationCount: number;
|
||||||
|
};
|
||||||
|
/** VersionRestoreResponse */
|
||||||
|
VersionRestoreResponse: {
|
||||||
|
/**
|
||||||
|
* Updatedat
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
|
|
@ -1046,7 +819,7 @@ export interface operations {
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Successful Response */
|
/** @description Successful Response */
|
||||||
200: {
|
201: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
@ -1375,6 +1148,75 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
upload_asset_api_v1_documents__id__assets_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["Body_upload_asset_api_v1_documents__id__assets_post"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_asset_api_v1_documents__id__assets__ref__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
ref: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
get_annotations_api_v1_documents__document_id__annotations_get: {
|
get_annotations_api_v1_documents__document_id__annotations_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -1441,11 +1283,13 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
health_check_api_v1_health_get: {
|
list_versions_api_v1_documents__document_id__versions_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: never;
|
||||||
|
|
@ -1456,23 +1300,161 @@ export interface operations {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": components["schemas"]["VersionListResponse"];
|
||||||
[key: string]: string;
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
create_version_api_v1_documents__document_id__versions_post: {
|
||||||
verify_coords_api_v1_debug_verify_coords_post: {
|
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["VerifyCoordsRequest"];
|
"application/json": components["schemas"]["VersionCreateRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["VersionMeta"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_version_api_v1_documents__document_id__versions__version_id__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["VersionDataResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_version_api_v1_documents__document_id__versions__version_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
restore_version_api_v1_documents__document_id__versions__version_id__restore_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["VersionRestoreResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export_document_api_v1_documents__document_id__export_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
document_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ExportRequest"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
|
|
@ -1496,4 +1478,26 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
health_check_api_v1_health_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
1245
openapi_pretty.json
1245
openapi_pretty.json
File diff suppressed because it is too large
Load diff
|
|
@ -104,7 +104,9 @@
|
||||||
"kind": { "type": "string", "enum": ["rect", "ellipse", "line", "arrow"] },
|
"kind": { "type": "string", "enum": ["rect", "ellipse", "line", "arrow"] },
|
||||||
"strokeColor": { "type": "string", "default": "#000000" },
|
"strokeColor": { "type": "string", "default": "#000000" },
|
||||||
"fillColor": { "type": "string", "default": "transparent" },
|
"fillColor": { "type": "string", "default": "transparent" },
|
||||||
"strokeWidth": { "type": "number", "default": 2 }
|
"strokeWidth": { "type": "number", "default": 2 },
|
||||||
|
"start": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
|
||||||
|
"end": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }
|
||||||
},
|
},
|
||||||
"required": ["kind"]
|
"required": ["kind"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue