-
Notifications
You must be signed in to change notification settings - Fork 0
architecture
SaiLoR is a single-codebase React app that runs as both an Electron desktop application and a static web SPA. The key architectural seam is the PlatformAdapter interface, which abstracts all file I/O and PDF loading so the React UI is identical in both runtimes.
The entire file-system and PDF-loading layer is abstracted behind a single interface:
src/platform/adapter.ts → PlatformAdapter interface
src/platform/index.ts → getPlatform() singleton factory
src/platform/electron.ts → ElectronAdapter
src/platform/browser.ts → BrowserAdapter
PlatformAdapter (src/platform/adapter.ts) defines nine operations:
-
getRecents()— return the list of recently opened projects (RecentEntry[]withid+name) -
openRecent(id)— re-open a project by its recent-entry id (path on Electron, IndexedDB handle key on browser) -
openProject()— show an open dialog/picker, return JSON text + aSaveHandle -
saveProject(text, handle)— write back to the handle's location -
rebasePdfPaths(pdfPaths, from, to)— re-express PDF paths that were relative tofrom's directory as relative toto's. "Save as" depends on this: a paper'spdfis stored relative to the project file, so writing the old paths to a new location left every PDF pointing at nothing.store.saveAs()therefore picks the destination first (pickProjectLocation), rebases, and only then serializes and writes. Electron does the real path math via apaths:rebaseIPC (relative(dirname(to), resolve(dirname(from), rel))); the browser has no paths and returns the input unchanged. -
getPdfSource(pdfPath, projectHandle)— resolve a paper's relative PDF path into a URL react-pdf can load. On Electron it re-asserts the main process's project directory fromprojectHandlefirst, so PDFs always resolve against the project actually being rendered (the project editor repoints that directory when picking a location).
Three more exist for the project editor (see below):
-
pickProjectLocation(suggestedName)— ask where the project JSON should live; writes nothing. Returns aProjectLocation(handle,name, and — Electron only — an absolutepath). -
pickPdfs()— pick PDFs to reference; returnsPickedPdf[](name, plus an absolutepathon Electron). -
relativePdfPaths(pdfs, location)— thepdfvalues to store, relative to the JSON's directory. Electron computes real relative paths via IPC; the browser returns bare file names (the File System Access API exposes no paths).
Project title. A project JSON may set a top-level title; the app shows it wherever it would otherwise show the file name (toolbar, recents list, Open menu), falling back to the file name when absent. It is a first-class key on Project (not swallowed into extra, which would duplicate it on save) and is only written when non-empty. The project editor exposes it as a Project title field next to the JSON location.
Recents are re-read from disk, not trusted. The title shown for a recent is stored on the entry, but that copy goes stale the moment the project is renamed elsewhere — most obviously by changing the Project title in the editor, where the old name would otherwise still be sitting in the list after closing it. So checkRecents() re-reads each project's current title from the file: on Electron a project:peek IPC returns { exists, title } per path in one round trip (parse failures are contained per file); in the browser it reads through the retained handle, but only when read permission is already granted — startup must never throw a permission prompt at the user just to refresh a label, so without permission the stored title is kept. The refreshed list is written back (replaceRecents, order preserved) so the fresh titles survive a restart, and editorStore.save() triggers a refresh so a rename shows up as soon as the editor is closed. A title removed from a file correctly reverts the entry to showing its file name.
A recent whose file has gone is kept, not forgotten. checkRecents() (run on startup via refreshRecents()) re-tests each entry — fs:exists on Electron, "do we still hold the IndexedDB handle" in the browser — and sets a runtime-only available flag (never persisted). A missing project stays in the list, faded, badged not found, and unselectable, because the drive may come back; the user can still dismiss it with the ×. Opening one that has since vanished marks it unavailable rather than pruning it. Note the flag is deliberately tri-state: undefined means not checked yet, and only an explicit false greys an entry out, so nothing flickers on first paint.
Closing a project (requestCloseProject) returns to the start screen, and when there are unsaved changes it first asks via ClosePrompt — the same three choices and wording as Electron's native quit dialog (Save / Don't Save / Cancel). A failed save keeps the project open rather than closing and losing the work.
Recents entries carry path and title beyond id/name. The path is what lets a user tell two projects called review.json apart: the start screen shows it under the title (truncated from the left, since the tail is what differs — direction: rtl + unicode-bidi: plaintext, the latter so the leading / isn't reordered to the end), and the Open menu shows it as a hover tooltip. On the start screen each entry also carries a ✎ pen (useEditorStore().startEditRecent(id)) that opens the project straight into the schema editor instead of the annotation view — the same edit path as Edit annotation JSON…, but for a known recent rather than a file picker. Both places offer an × to drop an entry (forgetRecent), which in the menu deliberately does not close it, so several can be cleared in a row. The title is only known once the JSON is parsed, so loadFromText re-pushes the entry via rememberProject() — pushRecent dedupes by id, so this enriches the existing entry rather than duplicating it.
Recent projects are managed by src/platform/recents.ts — a platform-opaque module that stores up to 5 entries in localStorage (separate keys for Electron and browser). On Electron, the entry id is the absolute file path; on browser it is a key into the IndexedDB handle store.
getPlatform() (src/platform/index.ts) returns a singleton: ElectronAdapter if window.slr exists (preload bridge), otherwise BrowserAdapter. Detection uses isElectron() which checks for the preload-bridged window.slr object.
Delegates to window.slr (the preload bridge). File operations use IPC to the main process. PDFs are served via the custom slr-file://project/<encoded-path> protocol — the main process resolves paths relative to the project directory. setProjectDir is called on open/save-as so the protocol knows the base directory. On open and save-as, the adapter pushes an entry to the recents list (slr.recents.electron localStorage key). openRecent(id) calls bridge().openPath(id) to read a file by absolute path; if the file no longer exists the entry is pruned from recents.
Has three tiers of capability:
| Capability | Chromium (FSAPI) | Other browsers | Server mode |
|---|---|---|---|
| Open |
showOpenFilePicker — in-place handle retained |
Hidden <input type=file>
|
fetch(url) via ?project=
|
| Save |
createWritable on retained handle |
Download blob | Download blob |
| Save as | showSaveFilePicker |
Download blob | Download blob |
| PDF loading | One-time directory grant via showDirectoryPicker, blob URL |
fetch relative to page |
fetch relative to project URL |
setServerBase(url) records the URL a project was fetched from so sibling PDFs resolve correctly in server mode. The adapter stores FileSystemFileHandle references in an internal map keyed by generated IDs.
When the File System Access API is available, the BrowserAdapter also persists handles in IndexedDB (src/platform/idb.ts) so they survive page reloads. openProject() and saveProject() (via the FSAPI Save As flow) call rememberHandle() which stores the handle under a recent:<name> key and pushes an entry to slr.recents.browser. openRecent(id) retrieves the handle from IndexedDB, re-requests read permission (via ensureReadPermission), and re-reads the file. If the handle is missing or permission is denied, the entry is pruned from recents. Opening a local file resets serverBase to null so PDF loading uses the handle instead of server fetch.
The entire app state lives in a single Zustand store with immer middleware:
src/state/store.ts → useStore
| Field | Type | Purpose |
|---|---|---|
project |
Project | null |
The loaded, normalized project (schema + papers) |
currentPaperId |
string | null |
Currently selected paper |
saveHandle |
SaveHandle | null |
Where to write back (path, FSAPI handle id, or download) |
projectName |
string |
Display name for title bar |
dirty |
boolean |
Unsaved changes flag; gates beforeunload guard |
loadError |
LoadError | null |
Error overlay data |
busy |
boolean |
Disables toolbar buttons during async operations |
sidebarCollapsed |
boolean |
Paper list visibility. Driven by SidebarToggle, which renders inside the paper list's own header while it is open and in the toolbar once collapsed — otherwise the button would disappear with the pane it reopens. |
pdfSelection |
string |
Latest text selected in the PDF viewer (for "grab from PDF") |
theme |
Theme ('light' | 'dark') |
Current app theme (persisted in localStorage via src/state/settings.ts) |
fontScale |
number |
Current font scale factor (0.7–2.0, persisted in localStorage) |
pdfZoom |
number |
PDF zoom multiplier (0.4–3.0, session-only, default 1) |
recents |
RecentEntry[] |
Recently opened projects (max 5, from platform.getRecents()) |
helpOpen |
boolean |
Help dialog visibility |
past / future
|
HistoryEntry[] |
Undo/redo stacks for annotation edits (session-only, capped at 100). Each entry is { project, paperId }; thanks to immer's structural sharing, snapshots are cheap references |
-
openProject()— delegates toplatform.openProject(), thenloadFromText(), refreshesrecents -
openRecent(id)— delegates toplatform.openRecent(id); on success →loadFromText+ refreshesrecents; on null → prunes recents and setsloadError -
loadFromUrl(url)—fetchthe project JSON, setserverBaseon browser adapter, thenloadFromText() -
loadFromText(text, handle, name)— callsloadProject(text)from the model layer, sets state, selects first paper -
save()/saveAs()—serializeProject(project)→ delegate to platform, clearsdirty, refreshesrecents -
selectPaper(id)— switches paper, clearspdfSelection -
setFieldValue(path, name, index, value)— navigates the annotation tree viacontainerAt(), setsinst.value, marks dirty -
addInstance(path, def)/removeInstance(path, name, index)— manages repeatable annotation instances, respectsmax/min -
toggleTheme()/setTheme(theme)— flips or sets the app theme, applies viaapplyTheme()(setsdata-themeattribute on<html>) -
increaseFont()/decreaseFont()/resetFont()— adjustsfontScaleby ±0.1 (clamped to 0.7–2.0), applies viaapplyFontScale()(sets--app-font-scaleCSS variable) -
zoomInPdf()/zoomOutPdf()/resetPdfZoom()— adjustspdfZoomby ±0.2 (clamped to 0.4–3.0, rounded to 2 decimals) or resets to 1; session-only, not persisted -
undo()/redo()— swap the current project snapshot with one from thepast/futurestack (and switch to the affected paper). The mutating actions push a snapshot before applying; consecutive edits to the same field coalesce into one undo step (a module-levellastFieldKeytracks this), while add/remove/paper-switch reset it. History is cleared on project load. -
setHelpOpen(open)— shows/hides the help dialog
The containerAt(root, path) helper walks the annotation tree following PathSeg[] (name + index pairs) to reach the container for a given path.
App (src/App.tsx)
├── Toolbar (src/components/Toolbar.tsx)
│ Open ▾ dropdown (Open file… + recent projects) + Save ▾ dropdown (Save / Save as…)
│ Font controls (A− A A+), theme toggle (☾/☀), help (?)
├── [if project loaded: workspace — a CSS grid whose column widths come from resizable panes]
│ ├── PaperList (src/components/PaperList.tsx)
│ │ List of papers with search box; green dot if hasAnnotations(); click to select
│ ├── Splitter (src/components/Splitter.tsx) ×2 — drag handles between the panes
│ ├── PdfViewer (src/components/PdfViewer.tsx)
│ │ react-pdf Document+Page; ResizeObserver for width; zoom controls; multi-page navigation; jump history (back/forward); in-PDF search (Ctrl+F); text selection capture
│ └── AnnotationPanel (src/components/AnnotationPanel.tsx)
│ └── AnnotationNode (src/components/AnnotationNode.tsx) [recursive]
│ └── Field (src/components/Field.tsx)
│ Input control (text/number/checkbox/enum ComboBox) + ⧉ grab-from-PDF button
├── [if no project: welcome screen with "Open project…" button + recent projects list]
├── HelpDialog (src/components/HelpDialog.tsx)
│ Modal overlay with app intro + keyboard shortcuts table
└── ErrorPanel (src/components/ErrorPanel.tsx)
Modal overlay for load/save errors
Dropdown (src/components/Dropdown.tsx) is a reusable click-to-open menu. Items are a union of item (label, optional shortcut, disabled, onSelect), separator, or header. It closes on outside-mousedown, Escape, or item selection. The Toolbar uses two Dropdown instances: Open ▾ (with recent projects list) and Save ▾ (with shortcut labels).
AnnotationNode is the core recursive component. Given a ResolvedDef and a container (AnnotationValueTree), it:
- For a single non-repeatable leaf — renders one
Fieldon a single row (the common case). - For repeatable or group nodes — renders a header with "+ Add" button (if repeatable), then iterates instances. Each instance shows a remove ("×") control (if repeatable), an optional field, and recursively renders children with an extended
path.
The path (PathSeg[]) is extended at each nesting level: [...path, { name: def.name, index: i }]. This path is passed to store actions to navigate the tree.
Field renders the appropriate input based on def.type:
-
boolean→ checkbox -
number→<input type=number> -
stringwithoptions(enum) →ComboBox(src/components/ComboBox.tsx) — a filterable dropdown of allowed values; no free-text grab button -
stringwithoutoptions→ auto-expanding<textarea>(single line when idle, grows on focus up to 240px, max 500 chars)
The grab-from-PDF button (⧉) reads useStore.getState().pdfSelection and inserts it. It is shown for string (non-enum) and number fields. For number fields, it extracts the first numeric token via parseNumber() (handles comma decimals).
src/components/NodeName.tsx renders schema node names. When a definition has a description, the UI adds an ⓘ marker, shows the description as a hover/focus tooltip, and renders that tooltip in a portal so it is not clipped by the annotation panel scroll container. The wrapper also includes an aria-label that combines the name and description for assistive technology.
The start screen shows the running version at the bottom (SaiLoR v0.1.0) and, when a newer release exists, a notice linking to it. src/model/version.ts fetches api.github.com/repos/Gram21/SaiLoR/releases/latest and compares the tag with __APP_VERSION__ — injected from package.json by vite.config.ts, so package.json stays the single source of truth for the version.
This only works while the repository is public. GitHub answers 404 to an unauthenticated request for a private repo's releases, and the app is distributed — embedding a token to get around that would ship a credential to every user, so we don't. The check is therefore written to fail silently: a 404 (private), 403 (rate-limited), network error, draft release, or unparseable version all yield null, and the app simply shows no notice. It starts working the moment the repo is made public, with no code change.
When a newer release exists, the notice offers a direct download of the installer for this machine rather than dumping the user on the releases page: pickInstaller matches the release's assets against the OS/arch reported by getOsInfo() (Electron exposes process.platform/process.arch through the preload bridge; the browser returns null, since a web deployment has no installer and updates by redeploying). Matching keys off the OS/arch we bake into the artifact names (SaiLoR-0.2.0-macos-arm64.dmg), so it stays correct as long as package.json's artifactName patterns do. When nothing matches it offers nothing rather than the wrong binary, and the release-notes link is always there as a fallback.
isNewerVersion compares numeric components, not strings (0.10.0 > 0.9.0, which a string compare gets wrong), strips a leading v, and sorts a pre-release below its release (1.0.0-beta < 1.0.0). It never claims an update from a version it cannot parse.
The result — including a null — is cached in localStorage (slr.updateCheck) for 24 h, so a private repo or an offline launch doesn't re-request on every startup, and the 60-requests-per-hour unauthenticated rate limit is never a concern.
src/model/validate.ts checks a reviewer's annotations against the schema; the Validate button in the toolbar runs validateProject(project) and ValidationDialog shows the result grouped by paper (click a paper to jump to it). Four issue kinds: required (a field marked required is empty), type (the stored value doesn't match the field's type — the JSON is hand-editable), enum (a value outside the field's options), and cardinality (an instance count outside [min, max]).
Emptiness is the load-bearing definition, and booleans are the special case: a boolean field is never empty. An unticked box is a real answer (false), and a missing/null boolean reads as false — so a required boolean can never raise a required issue. 0 is likewise a real number, and ''/whitespace is empty only for strings. A type mismatch suppresses the required/enum checks for that field, so one broken value yields one issue rather than a cascade. Everything is defensive: a malformed tree produces issues rather than throwing.
Fields are marked required by required: true in the schema (ResolvedDef.required, defaulting to false; rejected on a group, which holds no value). The schema editor exposes it as a Required checkbox on non-group rows, and the annotation form marks such fields with a red *.
Note that loadProject normalizes the tree to each node's min/max, so in practice cardinality issues only arise from a project that bypasses the loader.
A second screen (src/components/ProjectEditor.tsx, shown instead of the workspace while useEditorStore().open) lets users create or edit a project JSON — its annotation schema and the PDFs it references — without hand-writing JSON. It is entered from the welcome screen's New annotation JSON… / Edit annotation JSON… buttons, or from the ✎ pen on a recent project (startEditRecent, which loads that recent by id rather than prompting a file picker; startEdit and startEditRecent share editorStateFromOpened).
The help dialog is mode-aware. HelpDialog derives a mode from useEditorStore().open and whether a project is loaded, then renders one of three guides, each with only the shortcuts that actually do something there, plus a badge in the title naming the mode:
- Getting started (start screen, nothing open) — what an SLR project JSON is, and what the three buttons do (open / new / edit).
- Annotating (a project is open) — pick a paper, read the PDF, fill the fields, save.
- Editing the annotation JSON (the editor is open) — schema building, drag-to-nest, adding PDFs, the two save buttons.
Shared sections (appearance, license) render in all three.
Two ways out of the editor: Save JSON writes the file and stays put (so you can keep building), while Save JSON & Begin Annotating writes it and hands it to the annotation view (loadFromText) — that split is save() vs saveAndAnnotate(). Both validate first, so an invalid draft neither writes nor closes. The editor has its own undo/redo history (past/future snapshots, same shape as the annotation store, with consecutive keystrokes in one input coalesced into a single step), and useKeybindings / useElectronCloseGuard route Ctrl+S / Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y to it whenever it is open.
-
src/state/editorStore.ts— a separate Zustand+immer store holding the draft. It deliberately works on the raw JSON shape, not the loadedProject: each paper'sannotationsobject is carried through verbatim while the schema is edited, so editing the schema never prunes existing annotation data (it is normalized against the new schema the next time the project is opened for annotating). Key pieces:EditorNode(a schema node with a client-sideuid, wherekind: 'group'means "notype" — a name-only sub-tree),EditorPaper(which also keeps the PDF's absolutesourcePath),toAnnotationDefs/fromAnnotationDefs(conversion to/from the compact on-diskAnnotationDef),moveNodeIn(tree move that refuses to drop a node into itself or its own subtree),buildProjectJson, andvalidateDraft— which runs the realprojectSchema+resolveSchemavalidators, so the editor cannot produce a file the loader would reject. On save it writes the JSON and hands it straight to the main store vialoadFromText. -
SchemaTreeEditor.tsx— recursive tree exposing the schema's full expressiveness: name, kind (Group / Text / Number / Yes-no),min,max(with an ∞ checkbox formax: null= unbounded repeats), description, enumoptions(string fields only), nesting, add/remove. Native HTML5 drag-and-drop reorders rows and builds nesting: the drop position comes from the pointer's Y within the target row — top 25% →before, bottom 25% →after, middle →inside(nest as a child). -
PapersEditor.tsx— add PDFs via a native/browser picker, edit each paper's id/title/authors/DOI and itspdfpath, reorder by drag, remove.
Adding PDFs (addPdfs) does two things beyond appending rows:
-
Duplicate rejection. A PDF already referenced is skipped rather than added twice, and the skipped names are reported in a dismissible notice. Identity is
pdfKeys(): the absolute path when known (Electron) and the stored relative path — so re-picking the same file, picking it twice in one dialog, or picking one already listed in an opened project all collapse to one entry. Same-named PDFs in different folders stay distinct. -
Title/author auto-fill.
src/model/pdfMeta.tsreads each added PDF (PickedPdf.read()— an IPC in Electron,File.arrayBuffer()in the browser) and pre-fills the fields. It tries the PDF's embeddedTitle/Authormetadata first, validating it (isPlausibleTitlerejects artefacts like "Microsoft Word - paper_final_v3.doc"), then falls back to a layout heuristic on page 1: the largest text near the top is the title (joined across wrapped lines), and the lines under it are the authors.parseAuthorListstrips affiliation superscripts, footnote daggers, emails and an "Authors:" label; instrictmode (used only for the heuristic, which is a guess) it also requires each entry to look like a person's name, so a body sentence can't become an author list. Everything is best-effort and only ever pre-fills — rows appear immediately with a name-derived placeholder, extraction patches them in the background, and it never overwrites a value the user has already typed. The pdf.js worker is configured once insrc/platform/pdfjs.ts, shared by the viewer and the extractor.
Relative PDF paths. The JSON's location is chosen up front (and changeable any time via Change…), because a paper's pdf is stored relative to the JSON file. Each picked PDF keeps its absolute sourcePath, so when the location changes changeLocation() re-derives every pdf against the new directory (/reviews/x.json + /reviews/pdfs/a.pdf → pdfs/a.pdf; move the JSON up a level and it becomes reviews/pdfs/a.pdf). This only works in Electron, where real filesystem paths exist: the platform methods pickProjectLocation / pickPdfs / relativePdfPaths (see below) compute it via a paths:relative IPC (path.relative(dirname(json), pdf), POSIX-separated). In the browser the File System Access API exposes no paths, so a picked PDF is stored as its bare file name and the user places it next to the JSON or edits the relative path by hand — the papers editor says so.
Uses react-pdf's Document + Page components. The pdf.js worker is loaded from the bundled dependency URL. A ResizeObserver tracks container width so pages scale to fit; the final render width is the fit-to-width size multiplied by the store-level pdfZoom factor. The PDF header shows the paper title, authors, and DOI, plus zoom controls (−, percentage, +) wired to zoomOutPdf / resetPdfZoom / zoomInPdf. For multi-page PDFs, the header also shows page navigation (prev/next buttons, a page-number input, and a total page count). The current page is tracked from scroll position via onScroll — the last page whose top has scrolled past 30% of the viewport height — and typing a page number jumps to that page. The PDF text and annotation layers are both rendered. Pages are align-items: safe center so horizontal scrolling remains reachable when zoomed wider than the pane. Text selection is captured via onMouseUp/onKeyUp → window.getSelection() → setPdfSelection().
External links. The <Document> is given externalLinkTarget="_blank" and externalLinkRel="noopener noreferrer", so link annotations pointing at a website render as target="_blank" anchors and open in a new browser tab rather than navigating the SPA away. Internal (in-PDF) destinations are unaffected — pdf.js renders them without a target and its LinkService scrolls to them. In Electron, main.ts intercepts the resulting window.open with webContents.setWindowOpenHandler, hands the URL to shell.openExternal (the user's default browser), and returns { action: 'deny' } so no Electron window is created. A will-navigate listener is a safety net that prevents anything from navigating the app window away, routing off-app URLs to the browser too. Both paths go through openExternalUrl, which only opens http:/https:/mailto: — never file: or other schemes, which the OS could use to launch programs.
Jump history (back/forward). Clicking an internal PDF link (e.g. a reference) lets the pdf.js LinkService scroll to the destination. An onClickCapture on the scroll container notices clicks on <a> elements and, after polling briefly, records the pre-jump scrollTop on a back stack only if the view actually moved (so external links, which don't scroll, are ignored). Two header buttons (↩ / ↪, shown once history exists) then move between positions like a browser: jumpBack pops the back stack, pushes the live position onto the forward stack, and scrolls there instantly (scrollTo with default behavior — reliable under prefers-reduced-motion); jumpForward is symmetric. The stacks are refs (with canJumpBack/canJumpForward state mirroring their lengths) and are cleared when the paper changes.
In-PDF search. A 🔍 button in the header (and Ctrl/Cmd+F) toggles a find bar below the header; opening it focuses the input so the user can type immediately (via a searchOpen effect, since the input isn't mounted on the open transition). findMatches walks the text nodes of each rendered text layer (.react-pdf__Page__textContent), concatenating them per layer so a query can span multiple spans, and returns DOM Ranges. Matches are painted with the CSS Custom Highlight API (CSS.highlights + ::highlight(slr-pdf-search) / ::highlight(slr-pdf-search-active)) — this tints the transparent text-layer glyphs without mutating react-pdf's DOM, and degrades gracefully where the API is unavailable. The active match is centered in the scroll container; Enter / Shift+Enter (and the ‹ › buttons) cycle matches. Crucially, the <Page> elements are memoized (useMemo on [numPages, renderWidth, onTextLayerRendered]) with a stable onRenderTextLayerSuccess callback, so typing in the search box reuses the same element references and React skips re-rendering the pages — otherwise every keystroke would tear down and re-render the text layers (a "TextLayer task cancelled" flood) and matches would never resolve.
PDF source resolution is async: getPlatform().getPdfSource(paper.pdf, saveHandle) returns a { url, revoke? }. The effect cleans up (revokes blob URLs) on paper/handle change or unmount.
electron/main.ts is a thin main process:
-
Window:
BrowserWindowdefaulting to 1920×1080, context isolation enabled, node integration disabled, preload script loaded. The taskbar/dock icon is set frombuild/icon.pngvianativeImage(andapp.dock.setIconon macOS so it shows in dev, not just the packaged bundle). -
Window-state persistence: size, position, and maximized state are saved to
window-state.jsoninapp.getPath('userData')and restored on the next launch. Writes are debounced onresize/move/maximize/unmaximize(400 ms) and flushed synchronously onclose; maximized/fullscreen windows store theirgetNormalBounds()so restore returns to the user's chosen size. A saved position is only reused if it still overlaps a connected display (screen.getAllDisplays()), so a disconnected monitor can't strand the window off-screen; otherwise only the size is applied and the window is centered. Absent/corrupt state falls back to the 1920×1080 default. -
Dev vs prod: loads
VITE_DEV_SERVER_URLin dev,dist/index.htmlin production. -
External links:
setWindowOpenHandlersendstarget="_blank"links (external links in PDFs) to the system browser viashell.openExternaland denies the popup;will-navigateprevents any off-app navigation of the window itself. Onlyhttp:/https:/mailto:URLs are passed to the OS. -
slr-file://protocol: registered as privileged (secure, stream, fetch API, CORS-enabled). Handler resolves paths relative toprojectDirwith traversal guard (path.resolve+ prefix check). Returns 403 for traversal attempts, 404 for missing files.corsEnabledis required, not cosmetic: the renderer's origin (dev server, orfile://when packaged) differs fromslr-file://, so loading a PDF is a cross-origin request. Without it Chromium rejects the request beforeprotocol.handleruns, and pdf.js surfaces the opaque failure asUnexpected server response (0). -
IPC handlers:
-
project:open—dialog.showOpenDialog→readFile -
project:openPath— reads a file by absolute path (for re-opening recent projects); returnsnullif the file is missing or unreadable -
project:save—writeFileto given path -
project:saveAs—dialog.showSaveDialog→writeFile -
project:setDir— setsprojectDirfrom the project file's directory -
project:pickSavePath—dialog.showSaveDialogto choose where a project JSON should live; writes nothing (the project editor picks a location before there is a file) -
pdf:pick—dialog.showOpenDialogwithmultiSelectionsto add PDFs; returns absolute paths -
pdf:read— raw bytes of a PDF by absolute path, so the editor can read its title/authors. Deliberately not confined to the project directory (unlikeslr-file://): the user may add PDFs from anywhere and picked them through a native dialog. -
paths:relative—path.relative(dirname(fromFile), to)for each target, POSIX-separated. This is what makes a paper'spdfrelative to the JSON, and what re-derives the paths when the JSON moves. -
app:setDirty— the renderer reports its unsaved-changes state (drives the quit dialog) -
app:saveComplete— the renderer reports the result of a save it was asked to run before quitting
-
-
Menu: custom template with File, Edit, View, Window menus.
- The Edit menu is hand-built: Undo/Redo send
app:undo/app:redoto the renderer (routing to the store's history) rather than the native text-undo role, so undo works app-wide; cut/copy/paste/selectAll keep their native roles. - The View menu is hand-built (not the default
{ role: 'viewMenu' }) and deliberately omits zoom roles so thatCtrl +/-/0reach the renderer for PDF zoom (andCtrl+Shift +/-/0for app font scaling) instead of triggering native browser/Electron zoom.
- The Edit menu is hand-built: Undo/Redo send
-
Unsaved-changes quit flow: a window
closehandler (promptUnsavedChanges) intercepts the close/quit whenisDirtyis set, and shows a native Save / Don't Save / Cancel dialog. "Save" asks the renderer to save (app:requestSave) and closes once it reports back; "Don't Save" closes discarding changes. Abefore-quitflag lets the guard resumeapp.quit()after confirmation (so Cmd+Q fully quits on macOS, where destroying the window alone would not).
electron/preload.ts uses contextBridge.exposeInMainWorld('slr', ...) to expose IPC-backed methods: openProject, openPath (read file by absolute path), saveProject, saveProjectAs, setProjectDir, plus the quit/menu coordination — setDirty, onRequestSave, saveComplete, onUndo, onRedo. This window.slr object is the detection signal for isElectron().
-
useKeybindings(src/hooks/useKeybindings.ts): Global keyboard shortcuts registered onwindow.keydown. Handles open (Ctrl/Cmd+O), save (Ctrl/Cmd+S), save-as (Ctrl/Cmd+Shift+S), paper navigation (Alt+↓/↑,[/]), zoom/font (Ctrl/Cmd ++/=/-/0→ PDF zoom; add Shift → app font size), and help (F1). Paper navigation skips when typing in a field (unless Alt is held). Zoom/font detection matchese.keyande.codeto handle numpad and international layouts; reset is detected by the digit-0e.code(Shift-independent) to avoid the German Shift+0 →=clash. Copy/cut/paste/undo are left to the browser/Electron Edit menu. -
useDirtyGuard(src/hooks/useDirtyGuard.ts): Browser only. Registers abeforeunloadlistener that callse.preventDefault()whendirtyis true, triggering the browser's "unsaved changes" confirmation. It is skipped under Electron (isElectron()), because abeforeunloadthat returns a value there silently cancels the quit with no dialog — Electron handles unsaved changes via a native dialog in the main process instead (seeuseElectronCloseGuardand the quit flow below). -
useElectronCloseGuard(src/hooks/useElectronCloseGuard.ts): Electron only. Wires the renderer to the main process for a clean quit and for the Edit menu: it pushes the currentdirtystate to main (slr.setDirty), runs a save when main asks after the user picks "Save" in the native close dialog (slr.onRequestSave→save()→slr.saveComplete(ok)), and routes the Edit-menu Undo/Redo (slr.onUndo/slr.onRedo) to the store'sundo()/redo().
App appearance is controlled by a settings module that persists to localStorage:
-
Theme:
'light' | 'dark'— defaults to OS preference (prefers-color-scheme) on first load.applyTheme()setsdocument.documentElement.dataset.theme. CSS uses:root[data-theme='dark']selectors so the theme is user-controlled, not OS-only. -
Font scale: float from 0.7 to 2.0 (step 0.1).
applyFontScale()sets the--app-font-scaleCSS variable on<html>. The base font size iscalc(14px * var(--app-font-scale))and most text sizes useremunits so they scale proportionally. The PDF paper itself is always rendered on a white background regardless of theme. -
Pane widths: the left (paper list) and right (annotations) pane widths are persisted via
loadPaneWidths()/savePaneWidths()(localStorage, clamped).App.tsxholds them in state, applies them as the workspace grid template, and updates them as theSplitterdrag handles are dragged.
src/main.tsx calls applyTheme(loadTheme()) and applyFontScale(loadFontScale()) before rendering React to avoid a flash of unstyled or wrong-theme content on startup.
-
useKeybindings()anduseDirtyGuard()are called at the top level. - On mount, checks
?project=<url>query parameter — if present, callsloadFromUrl(url)for server-deployment auto-loading. - Renders
Toolbaralways. If a project is loaded, renders the three-pane workspace; otherwise shows a welcome screen with an "Open project…" button and, if there are recent projects, a clickable list of them wired toopenRecent(id). -
ErrorPanelis always rendered (renders null when no error).
vite.config.ts uses base: './' so the built SPA works from both a server subpath and file:// (for Electron). The ELECTRON=1 environment variable conditionally adds the vite-plugin-electron plugin which builds electron/main.ts and electron/preload.ts (preload emitted as .cjs since the package is type: module).
-
Adding a new platform operation: Add it to
PlatformAdapter, implement in bothElectronAdapterandBrowserAdapter, then call from the store or components. -
Adding a new annotation field type: Update
FieldTypeinschema.ts,emptyValue()inannotations.ts, andField.tsxrendering. Consider validation in the zod schema. -
Adding a new keyboard shortcut: Add to
useKeybindings.ts. CheckisEditable()if it should be ignored inside input fields. -
Changing the Electron IPC surface: Update
preload.ts(theSlrBridgeinterface),electron/main.ts(IPC handler), andsrc/platform/electron.ts(adapter method). All three must stay in sync. -
Adding a new settings/appearance option: Add to
src/state/settings.ts(load/apply functions), add state + actions to the store, add UI controls toToolbar.tsx, and add keybindings if needed.
These pages are a mirror. They are maintained in
openwiki/ in the repository and published
here automatically. Editing a page here is fine — the change is committed back to that folder — but
the folder is the source of truth. See Operations → Wiki sync.
Repository · README · Issues · Releases · Annotation schema guide
SaiLoR is free software under the GNU General Public License v3.0.
- Things to know
- Getting started
- Screening
- Working with several reviewers
- Setting up a project
- Git support
Developer documentation