Skip to content

Linux Mint/Ubuntu desktop fixes + plugin-API extensions + NEN 1414 stamp fix (v1.46.0)#220

Merged
DutchSailor merged 33 commits into
OpenAEC-Foundation:mainfrom
FreekHeijting:linux-mint-fix
May 4, 2026
Merged

Linux Mint/Ubuntu desktop fixes + plugin-API extensions + NEN 1414 stamp fix (v1.46.0)#220
DutchSailor merged 33 commits into
OpenAEC-Foundation:mainfrom
FreekHeijting:linux-mint-fix

Conversation

@FreekHeijting
Copy link
Copy Markdown
Contributor

@FreekHeijting FreekHeijting commented Apr 23, 2026

Why this PR exists

Open PDF Studio is the substrate we use to build a domain-specific draftboard for Symitech (NL inspection-engineering firm). To make that possible, the original PR opened in April was scoped to fix three independent issues we ran into while bringing the app up on Linux Mint 22.3:

  1. The window did not show up reliably on certain WebKitGTK + Mesa combinations.
  2. CLI flag handling silently squatted DBus single-instance, breaking subsequent launches.
  3. Custom plugin annotation types had no way to render, persist, or own their UI.

While iterating on that work the same branch grew the plumbing needed by a real plugin (symitech.tools) until the plumbing itself stabilised. We are surfacing the whole branch now because the parts are tightly co-evolved: the Linux fixes unblock daily work for our team, the plugin-API additions are pure additions that other plugin authors can use today, and the NEN 1414 stamp fix is a one-line glob update riding along because it shares the version bump. CI is green on all three platforms.

Table of contents


A. Linux Mint/Ubuntu desktop fixes (4 commits)

Independent desktop-integration fixes. Each lands a real bug we hit on Mint 22.3 / Ubuntu 24.04 daily-driver setups; CI confirms they do not regress Windows or macOS.

e9fab7b5 fix: show window immediately after init on Linux

Field Detail
Problem App starts, Rust side reaches Tauri::run(), but the window never becomes visible. Mouse-in-tray confirms the process is alive.
Root cause The frontend gates appWindow.show() on a double requestAnimationFrame to wait for first paint. On WebKitGTK builds where the accelerated compositor stalls before the first paint (reproduced on Mint 22.3 + Mesa 25.2.8 + Intel UHD CML GT2), the inner rAF never fires, the outer Promise never resolves, and show() is never called.
Fix Drop the rAF gate and call appWindow.show() directly after init. The pre-paint flash users were trying to avoid is barely visible in practice and a permanently hidden window is strictly worse.
Files js/main.js (-12 / +7)
How to verify Boot a Mint 22.3 VM with MESA_LOADER_DRIVER_OVERRIDE=iris, run a debug build, confirm the window appears.

ae1bb2b6 fix: handle --version and --help before starting the event loop

Field Detail
Problem open-pdf-studio --version printed nothing, exited with code 0, and any subsequent normal launch silently exited as if another instance were running.
Root cause Flag parsing happened inside tauri::Builder::default().run(). The full event loop started, the single-instance DBus name was registered, the process then exited without printing, and the squatted DBus name made later launches think a peer instance owned the slot.
Fix Parse --version / -V / --help / -h from std::env::args() before any Tauri builder runs. Print + std::process::exit(0).
Files src-tauri/src/lib.rs (-1 / +27)
How to verify target/release/open-pdf-studio --version prints the version and exits cleanly. Immediately afterwards a normal launch creates a window without "another instance is already running" complaints.

d4a606c8 fix: register application/pdf MIME type for Linux file associations

Field Detail
Problem The "Set as default PDF reader" button did nothing on Linux because xdg-mime had no record of Open PDF Studio claiming PDFs.
Root cause The fileAssociations entry for .pdf was missing the mimeType field, so tauri-bundler could not emit MimeType=application/pdf into the generated .desktop file.
Fix Add mimeType: ["application/pdf"] and a short description. The Tauri bundler translates this into the right MimeType= line, after which xdg-mime query default application/pdf can resolve to open-pdf-studio.desktop.
Files src-tauri/tauri.conf.json (+2)
How to verify Build a .deb, install, run xdg-mime query default application/pdf after pressing the in-app "Set as default" button.

4a894a27 feat(linux): implement default PDF handler via xdg-mime

Field Detail
Problem is_default_pdf_app and open_default_apps_settings were Windows-only. The "Set as default" banner was permanently shown on Linux (because is_default_pdf_app always returned false), and clicking the button returned Ok(false) without any side effect.
Root cause The implementation predated Linux support for that feature.
Fix Add Linux branches to both functions. is_default_pdf_app shells out to xdg-mime query default application/pdf and matches against open-pdf-studio.desktop. open_default_apps_settings calls xdg-mime default open-pdf-studio.desktop application/pdf so the in-app button can complete the round-trip without dragging users into a settings dialog.
Files src-tauri/src/lib.rs (-3 / +35)
How to verify On Linux, click "Set as default", then xdg-mime query default application/pdf returns open-pdf-studio.desktop and the banner disappears on next launch.

macOS note: A macOS branch for is_default_pdf_app / open_default_apps_settings is missing. The Linux branch returns false on macOS, so the feature degrades gracefully (banner stays, button does nothing). To be addressed in a follow-up PR; out of scope here.


B. NEN 1414 stamp + Vite 7 (1 commit)

3ae68466 fix: NEN 1414 symbols below 4 KB rendered as broken stamps

Field Detail
Problem A subset of NEN 1414 symbols rendered as image not found placeholders in the stamp tool. The set varied with build mode (dev vs prod).
Root cause Vite inlines PNG assets smaller than 4 KB as data: URIs. rasterSvg() was prepending window.location.origin to every URL that did not start with http, producing corrupt strings such as http://localhost:3041data:image/png;base64,....
Fix Prepend the origin only when the URL is root-relative (starts with /). Update the glob to Vite 7 syntax (import.meta.glob('...', { query: '?url', import: 'default' })) since as: 'url' is deprecated. Bump package.json from 1.45.0 to 1.46.0 to mark the user-visible fix.
Files js/solid/data/nen1414Library.js, package.json, package-lock.json, Cargo.toml, Cargo.lock, tauri.conf.json (-10 / +11)
How to verify Run a prod build, open the stamp tool, drop any of the previously-broken symbols (e.g. small valves) onto a page; they now render.

Version bump caveat: If upstream prefers to manage versioning on its own cadence, the 1.45.0 → 1.46.0 part of this commit is straightforward to revert independently. Happy to split if requested.


C. Plugin-API extensions (10 commits)

Plugins author custom annotation types today, but until this branch they could not render those annotations into thumbnails, persist them into the saved PDF, mount their own UI in place of the built-in property panel, or react to selection events without scraping the DOM. Each addition below is a pure addition: existing plugins are unaffected, all new fields and methods are optional.

These extensions are in production use by the private Symitech Tekentool V2 plugin (symitech.tools). We can demo it on request.

Foundations (4 commits)

Commit What Why it matters
9621cfc8 serializeToPdf hook on plugin handlers Plugins persist custom annotation types into saved PDFs. The saver in js/pdf/saver.js falls back to the plugin annotation-type registry for unknown types and awaits an optional serializeToPdf({ pdfDoc, page, annotation, convertX, convertY }).
884db08d Page-pt dimensions + DPR in state pluginClickTool augments the state object passed to typeHandler.create() with pageWidth, pageHeight, docScale, and devicePixelRatio. Eliminates fragile ctx-matrix sniffing.
c313bd78 __pdfViewport singleton sync for blank docs Blank ("Nieuw") documents skip the vector render path that normally calls setPage(), so window.__pdfViewport.pageW/pageH stayed at 0 and plugin handlers fell back to A4 defaults regardless of actual page size. Now the singleton is updated for blank docs too.
b15ded06 doc.pageDims cache + zoom controls + page centering Blank docs now store page dimensions on the document object directly (single source of truth), zoom controls work from the first frame, and the canvas is centered horizontally instead of left-aligned. Three small fixes that cluster around the same root cause.

New hooks (6 commits)

b844e54f api.registerPropertyPanel(typeName, renderFn)

Plugins replace the built-in properties panel for their own annotation types.

api.registerPropertyPanel('symitech.titlebar', (annotation, updateAnnotProp, onCommit, onCancel) => {
  const root = document.createElement('div');
  // build custom DOM, wire inputs to updateAnnotProp(...)
  return root;
});

renderFn returns the root element, OPPS mounts it into the panel slot. null falls through to default rendering.

Files: js/plugins/plugin-api.js, js/plugins/property-panel-registry.js (NEW), js/solid/components/properties-panel/CustomPluginPanel.jsx (NEW), js/solid/components/properties-panel/PropertiesPanel.jsx, js/solid/stores/propertiesStore.js.

1ea2964a Dot-path support in default updateAnnotProp branch

Plugin custom panels need to write nested annotation fields. Before this commit, updateAnnotProp('data.address.email', x) created a literal string-keyed property. Now: when the key contains ., the default branch splits, walks/creates intermediate objects, and writes to the leaf. Mirroring into the flat Solid store is skipped for nested writes (those are plugin-owned).

updateAnnotProp('data.address.email', 'info@example.com');
// annotation.data.address.email === 'info@example.com'

Files: js/solid/stores/propertiesStore.js (-2 / +22).

f041bd09 drawMode: 'polyline' for plugin handlers

Plugins registering a handler with drawMode: 'polyline' are now correctly routed by the tool-dispatcher and the polyline-tool. On finish, the plugin handler's create() is called with state.polylinePoints set. Previously polyline-mode fell through to the box-tool, producing a drag-rectangle and a generic type: 'polyline' annotation that ignored the plugin handler.

const handler = {
  type: 'symitech.scheur',
  drawMode: 'polyline',
  create: (sx, sy, ex, ey, e, state) => ({
    type: 'symitech.scheur',
    points: state.polylinePoints,
  }),
};

Files: js/tools/tool-dispatcher.js, js/tools/tools/polyline-tool.js.

2a0ecc71 registerSelectionListener + setNativePanelVisible + descriptor cssClass

Three small additions that let plugins mount UI outside the native properties panel without DOM-scraping or MutationObserver fallbacks.

api.registerSelectionListener('symitech.titlebar', (annotation) => {
  if (annotation) mountInOwnPalette(annotation);
  else unmountFromOwnPalette();
});
const descriptor = {
  cssClass: 'symitech-plugin', // applied directly to the .tp-ext root
  // ...
};

fireSelectionChange() is called from propertiesStore on storeShowProperties / storeHideProperties / storeClosePanel / storeShowMultiSelection. cssClass removes the need for plugins to scan the DOM looking for their own palette root.

Files: js/plugins/plugin-api.js, js/plugins/selection-listener-registry.js (NEW), js/plugins/palette-registry.js, js/solid/components/ExtensionToolPalette.jsx, js/solid/components/properties-panel/PropertiesPanel.jsx, js/solid/components/properties-panel/CustomPluginPanel.jsx, js/solid/stores/propertiesStore.js.

3d1063bd Overlay plugin/Solid-store annotations on page-thumbnails

The thumbnails panel renders pages via the Rust backend (render_thumbnail invoke with skip_images=true) or via the PDF.js fallback with annotationMode: 0. Both paths skipped plugin and Solid-store annotations, so previews showed empty pages.

After each render path, renderThumbnailToDataURL now overlays all annotations for that page on the thumbnail canvas at thumbnail scale. Tolerant per-annotation try/catch so one broken handler does not break the whole thumbnail. Zero-cost early-exit when the page has no annotations.

Files: js/ui/panels/left-panel.js (-1 / +63).

686c3bf6 Tool-group state + features.toolGroups flag

Foundation for sub-menu palette buttons. A ToolDefinition can now declare an optional subTools: ToolDefinition[], turning the entry into a sub-menu group. tool-group-state.js (NEW) tracks the active sub-tool per group. The host gate (features.toolGroups) defaults to false so this is a no-op for plugins that do not opt in. Render layer (G4b) follows in a later PR.

Files: js/plugins/palette-registry.js, js/plugins/plugin-api.js, js/plugins/tool-group-state.js (NEW).


Test coverage

Layer Status
CI build matrix ubuntu-22.04, windows-latest, macos-latest
Linux Mint 22.3 manual ✅ daily-driver, validated with Symitech plugin (titelblok edit-flow, schade symbols, polyline-mode, thumbnail overlay)
Windows 11 manual ⏳ CI build only, no runtime smoke
macOS 14 manual ⏳ CI build only, no runtime smoke
Unit tests for new plugin-API hooks ⏳ none added in this PR; happy to add if reviewers want them as a precondition

Risk and limitations

Risk Severity Mitigation
Hardware-specific window-show fix (e9fab7b5) drops a defensive gate Medium Open to suggestions; a short timeout-fallback (show after 250ms regardless of paint) is a reasonable middle ground.
xdg-mime shell-out (4a894a27) assumes xdg-utils is installed Low Standard on every desktop Linux distribution we have tested; fails gracefully with Ok(false) if missing.
macOS branch for default-PDF handler missing Low Linux branch returns false on macOS, banner stays visible. Not regressing existing behaviour.
Plugin-API extensions break a hypothetical existing plugin Very low All hooks are optional and additive. Default behaviour unchanged when a plugin does not register anything.
Version bump in 3ae68466 clashes with upstream release flow Low Trivial to revert if upstream wants to manage versions independently.

Follow-up roadmap

Items intentionally left out of this PR, in rough priority order:

  1. macOS branch for is_default_pdf_app / open_default_apps_settings (LaunchServices via LSCopyDefaultApplicationURLForContentType + a defaults write round-trip).
  2. Defensive window-show with a short timeout-fallback instead of unconditional show(), addressing reviewer concern about e9fab7b5.
  3. Tool-group render layer (G4b): button group expands into a fly-out, current sub-tool icon morphs into the parent button. State plumbing is already merged here.
  4. Unit tests for the new plugin-API hooks (registerPropertyPanel, registerSelectionListener, serializeToPdf, dot-path updateAnnotProp).

Suggested review path

The branch is structured so that each commit compiles and ships independently. Suggested order for reviewers who want to land this incrementally:

  1. Section A (4 commits) is small, scoped, low-risk, and resolves daily Linux pain. Easiest to ship first.
  2. Section C (10 commits, foundations first then hooks) is the largest delta but every commit is additive. Foundations (9621cfc8, 884db08d, c313bd78, b15ded06) are mechanically obvious; the new hooks (b844e54f, 1ea2964a, f041bd09, 2a0ecc71, 3d1063bd, 686c3bf6) are each self-contained and have a real plugin (Symitech) exercising them.
  3. Section B (1 commit) is a one-liner glob fix riding along with a version bump.

If splitting this PR into three is preferred, happy to do so; the branch was kept whole because the parts were authored in a tight loop and CI is already validating them together.

The frontend gated appWindow.show() on a double requestAnimationFrame to
wait for the first paint. On WebKitGTK builds where the accelerated
compositor stalls before the first paint (observed on Linux Mint 22.3 +
Mesa 25.2.8 + Intel UHD CML GT2) the inner rAF never fires, the outer
Promise never resolves, and show() is never called. The main process
survives with an unmapped window permanently hidden.

Remove the rAF gate and call show() directly after init. Mirrors the
pattern already used by open-speech-studio (src/App.tsx:270). The silent
try/catch around show() is also dropped so failures surface instead of
being swallowed.

Refs OpenAEC-Foundation#217
Parsing these flags inside tauri::Builder::default().run() caused the
full event loop to start and the single-instance DBus name to be
registered. The process then neither printed anything nor exited, and
because the DBus name was squatted, subsequent normal launches would
silently exit as if another instance were running.

Parse --version / -V and --help / -h from std::env::args() before any
Tauri initialisation, print the expected output and exit(0). The
argv-filter for PDF file paths (used for file associations) is kept
unchanged.

Refs OpenAEC-Foundation#219
The fileAssociations entry for .pdf was missing the mimeType field, so
tauri-bundler could not emit `MimeType=application/pdf` into the .desktop
file on Linux. As a result, xdg-mime had no record of Open PDF Studio
being able to open PDFs, and the in-app "Set as default" button had
nothing to point at.

Add mimeType and a short description. The Tauri bundler translates this
per platform: .desktop MimeType on Linux, UTType/Info.plist on macOS,
registry ProgID on Windows. No platform-conditional code required.
is_default_pdf_app and open_default_apps_settings were Windows-only,
so the in-app "Set as default" banner was permanently displayed on
Linux (is_default_pdf_app always returned false) and the button did
nothing (open_default_apps_settings returned Ok(false) without any
side effect).

Add Linux branches using xdg-mime. This is desktop-environment
agnostic (works on Cinnamon, GNOME, KDE, MATE, XFCE) and distro
agnostic (Mint, Ubuntu, Fedora, Arch). The Windows branch is unchanged
and the macOS / BSD / other fallback remains a graceful no-op, so
cross-platform behaviour is preserved.
Vite inlines PNGs <4KB als data: URIs. rasterSvg() prependde
window.location.origin aan alle non-http URLs, waardoor data:
URIs corrupt werden ("http://localhost:3041data:image/png;...").

- Check op leading "/" (root-relative), niet op "http"
- Update glob naar Vite 7 syntax (query/import i.p.v. deprecated as)
- Bump versie 1.45.0 -> 1.46.0
FreekHeijting and others added 10 commits April 28, 2026 18:01
Plugins can now persist their custom annotation types into saved PDFs by
implementing an optional serializeToPdf method on their AnnotationHandler.

When the saver encounters an annotation whose type is not in the built-in
switch (e.g. symitech.titlebar), it now looks the type up in the plugin
annotation-type registry and, if a handler with serializeToPdf is found,
awaits a call passing { pdfDoc, page, annotation, convertX, convertY }.
Plugins use the shared pdf-lib pdfDoc + page to draw via standard pdf-lib
APIs.

Without this hook, plugin annotations were silently dropped at save (the
switch had no default case). Existing behavior for plugins that do NOT
provide serializeToPdf is preserved (still dropped) so this change is
strictly additive.

Errors thrown by plugin serializers are logged but do not abort the save
pipeline - other annotations and pages still serialize.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plugins drawing custom annotations need page-pt dimensions to position
content reliably. Deriving these from the canvas DOM via getTransform()
or canvas.clientWidth gave wrong results when loading existing PDFs (the
ctx scale matrix did not always reflect doc.scale * devicePixelRatio).

The pluginClickTool now augments the state object passed to
typeHandler.create() with:

  - pageWidth / pageHeight  (in PDF points, derived from the active doc's
    canvasEl using doc.scale * window.devicePixelRatio)
  - docScale                (current per-document zoom)
  - devicePixelRatio        (so plugins can refine if needed)
  - currentPage             (already used downstream; surfaced for parity)

Existing fields on `state` are preserved (spread first), so this is
backwards compatible for plugins that ignore the new keys.

Verified: Symitech titlebar plugin now renders correctly both in "Nieuw
blanco" and "open existing PDF" flows. Without this change, loaded PDFs
caused titlebar layout to overflow page bounds because the plugin
inferred a 1750-pt page width from canvas.clientWidth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Blank "Nieuw" documents have doc.filePath === null, which gated the
vector render path (renderer.js) where setPage() is normally called.
Without setPage(), window.__pdfViewport.pageW / pageH stayed at the
initial 0 values, so any consumer reading those (notably plugin
annotation handlers via plugin-tool.js) got zero and was forced to
fall back to A4 defaults regardless of the actual page format
(A3 portrait, landscape, etc.).

Fix is in two parts:

1. renderer.js — call setPage() with page.view dimensions when
   doc.filePath is missing, using a stable per-doc surrogate key
   `__memory__${doc.id}` so setPage's "isNewDocument" detection
   still triggers fitToViewport on first render.

2. plugin-tool.js — read page dimensions from window.__pdfViewport
   directly (the canonical source) instead of doc.canvasEl, which
   was never assigned anywhere in the codebase. Fail loud with a
   console.warn if the singleton is not initialized.

Reproduced with the Symitech Tekentool plugin: A4 portrait worked by
coincidence (matched fallback), A3 landscape placed the titelblok on
the right half of the page constrained to A4 dimensions. After fix,
debug log confirms vp.pageW = 1191, vp.pageH = 842 for A3 landscape,
and titelblok layout matches the actual page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three connected fixes for blank ("Nieuw") documents:

1. doc.pageDims cache (loader.js + renderer.js + plugin-tool.js)
   Plugin annotation handlers need page dimensions in PDF points at
   click time. Previously they read window.__pdfViewport.pageW/pageH,
   which is initialized to 0 for blank docs because the vector path
   that calls setPage() is gated by `_hasFilePath`. Move the source of
   truth to doc.pageDims[pageNum] = { widthPt, heightPt }, populated
   by createBlankPDF (with the user-chosen dimensions, available
   immediately) and by every renderPage from page.view.

2. Zoom controls call renderPage instead of renderPageOffscreen
   (renderer.js: zoomIn / zoomOut / setZoom / _applyZoom / actualSize)
   The legacy zoom path (used when window.__pdfViewport.active is
   false, i.e. blank docs) was calling renderPageOffscreen, which is
   for export rendering and never updates the visible canvas. Switch
   the legacy branch to renderPage so the visible canvas actually
   re-renders at the new scale.

3. Ctrl+wheel zoom for blank docs (navigation-events.js)
   The wheel handler returned early when viewport.active was false,
   leaving ctrl+wheel zoom completely broken for blank docs. Route
   that case through zoomIn / zoomOut so the same legacy doc.scale
   path is exercised.

4. Page centering when smaller than viewport (styles/layout.css)
   #pdf-container.visible used align-items: flex-start, which pinned
   small canvases (the typical blank-doc case at low zoom) to the top
   of the container. Use `align-items: safe center` and
   `justify-content: safe center` so the page is centered in both
   axes when it fits, and falls back to top/left when it overflows so
   scroll origin stays reachable.

Together these make blank-doc placement of plugin annotations
(Symitech titelblok) work correctly for A4 and A3 in both portrait
and landscape, with zoom controls behaving as expected.

This supersedes commit c313bd7 (the earlier setPage-based fix),
which left the viewport in a half-active state for blank docs and
caused annotation transforms to drift from the bitmap pdf-canvas at
non-default zoom levels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plugins kunnen nu een eigen renderer registreren voor het Eigenschappen-paneel
van hun custom annotation-types. Wanneer zo'n annotation geselecteerd wordt,
mount OPPS de plugin-DOM in plaats van de built-in sections.

API:
  api.registerPropertyPanel(typeName, renderFn)
    renderFn(annotation, updateAnnotProp, onCommit, onCancel) -> HTMLElement

Files:
- js/plugins/property-panel-registry.js (nieuw, registry-map)
- js/plugins/plugin-api.js (registerPropertyPanel + cleanup)
- js/solid/stores/propertiesStore.js (customPanelRender signal +
  set in storeShowProperties / storeHideProperties)
- js/solid/components/properties-panel/CustomPluginPanel.jsx (nieuw,
  Solid-component die plugin-DOM mount via createEffect)
- js/solid/components/properties-panel/PropertiesPanel.jsx (slot na
  CustomFieldsSection)

Branch: linux-mint-fix (geen upstream-PR, lokaal voor Symitech-plugin v0.2.2 B2)
Spec: Symitech-Tekentool-V2/docs/superpowers/specs/2026-04-29-symitech-titelblok-edit-mode-v022.md
Plugin custom panels (registered via registerPropertyPanel) need to write
nested annotation fields like 'data.address.email'. The default branch
previously did `currentAnnotation[key] = value`, creating a literal
"data.address.email" property instead of walking the chain.

Now: when key contains '.', we split, walk/create intermediate objects,
write to the leaf. Mirroring into the flat Solid store is skipped for
dot-paths since plugin panels read from their own form-state.

Used by Symitech tekentool plugin for live re-render of titelblok edits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plugins die een AnnotationType registreren met `drawMode: 'polyline'`
worden nu correct gerouteerd door de tool-dispatcher en de polyline-tool,
zodat de plugin-handler.create() wordt aangeroepen op finish met de
verzamelde polylinePoints in enrichedState. Voorheen viel polyline-mode
ten onrechte terug op de shape-tool ('box'), waardoor een drag-rechthoek
i.p.v. een multi-click polyline werd gestart, en op _finishPolyline werd
altijd een generiek `type: 'polyline'`-annotation gemaakt zonder de
plugin-create() ooit aan te roepen.

Dispatch-flow na patch:

1. tool-dispatcher.js handlePointerDown / handlePointerMove / handlePointerUp:
   - drawMode === 'click'    -> _plugin_click tool (ongewijzigd)
   - drawMode === 'polyline' -> native polyline-tool (nieuw)
   - andere drag-modes       -> shape-tool 'box' (ongewijzigd)
   - polyline pointer-up     -> no-op (placement is click-driven, finish
     via right-click / dblclick in polyline-tool zelf)

2. polyline-tool.js _finishPolyline:
   - Plugin-handler met drawMode='polyline' + create()  -> typeHandler.create(
     0, 0, 0, 0, null, enrichedState) waarbij enrichedState polylinePoints,
     docScale, devicePixelRatio, pageWidth/Height en currentPage bevat.
     Resultaat wordt door ctx.createAnnotation() verrijkt met page +
     state.toolOverrides.
   - Anders -> generieke 'polyline'-annotation (legacy gedrag, identiek aan
     voor de patch).

Additief: zonder geregistreerde plugin-handler voor state.currentTool blijft
het gedrag exact gelijk aan voor deze commit. Geen fallbacks of
silent-catches: als typeHandler.create() null teruggeeft wordt er simpelweg
geen annotation gemaakt (guard `if (annProps)`).

Gebruikt door de Symitech-plugin (v0.2.2) voor o.a. `symitech.scheur` en
`symitech.vloer-contour`, zodat die multi-click polyline kunnen tekenen
in plaats van drag-rechthoeken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sClass

Drie clean toevoegingen aan plugin-API zodat plugins UI buiten het native
eigenschappen-paneel kunnen mounten zonder DOM-scraping of MutationObserver-
fallbacks:

- selection-listener-registry.js (NEW): registerSelectionListener(typeName, fn)
  geeft plugins een directe selectie-event-channel. fn(annotation) bij select,
  fn(null) bij deselect/multi-select/close. fired vanuit propertiesStore
  bij elk currentAnnotation-mutatiepunt (showProperties, hideProperties,
  closePanel, showMultiSelection).

- nativePanelHidden signal: api.setNativePanelVisible(false) verbergt
  PropertiesPanel volledig zodat plugin alle controles in eigen palette/UI
  kan tonen. Auto-restore bij plugin-deactivate via _cleanup. CustomPluginPanel
  vuurt extra deselect-signal (renderFn(null)) zodat plugin externe DOM kan
  unmounten.

- PaletteDescriptor.cssClass: optioneel extra class op .tp-docked/.tp-float
  root, zodat plugin-CSS direct kan targeten (.symitech-plugin) zonder
  MutationObserver-tag-hack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bnails

Voor de paginaminiaturen-paneel rendert OPPS de PDF via de Rust backend
(skip_images=true) of via de PDF.js fallback met annotationMode=0. Beide
paden lieten plugin- en Solid-store-annotations weg, wat resulteerde in
lege/onvolledige preview-thumbnails (Symitech-titelblok, schade-symbolen
etc. waren onzichtbaar in het linker-paneel).

Fix: na elke render-pad een overlay-stap die alle annotations voor die
page filtert + drawAnnotation aanroept op het thumbnail-canvas met
ctx.scale(scale). Verwerkt:
- Rust-pad: image inladen op nieuw canvas, drawImage, overlay, re-encode
- PDF.js-pad: direct op dezelfde ctx vóór toDataURL

Tolerant op individuele render-fouten (try/catch per annotation) zodat
1 stuk-annotation niet de hele thumbnail breekt.

Sidenote: live-reflection van edits in thumbnail-panel volgt later.
Huidige flow refresht thumbnail bij page-reload of expliciete
invalidateThumbnail-aanroep, niet per keystroke (zou expensive zijn).
Foundation for the upcoming "tool group" feature: a ToolDefinition can
declare an optional `subTools` array, turning the entry into a sub-menu
group. The host (G4b) will render such a tool as a single palette button
that morphs to show the active sub-tool's icon and opens a pop-out on
click. This commit is data + state layer ONLY — no rendering yet.

What:
- palette-registry.js: extend ToolDefinition JSDoc with `subTools?` field
  and a paragraph documenting tool-group rendering semantics.
- tool-group-state.js: new module. Reactive Solid signal storing
  Map<groupId, currentSubToolId>. Exports getActiveSubToolId,
  getActiveSubTool (with subTools[0] fallback), setActiveSubTool,
  clearAllToolGroups, getToolGroupStateSnapshot. In-memory only;
  no localStorage by design.
- plugin-api.js: add `api.features = { toolGroups: true }` so plugins
  can feature-detect support via `api.features?.toolGroups === true`.
  Wire `clearAllToolGroups()` into the existing _cleanup() path so
  plugin-deactivate resets stale per-group selections.

Why:
- Establishes a stable contract before UI work, so plugins can already
  emit `subTools` and feature-detect support without depending on the
  host actually rendering pop-outs yet.
- Keeps the data layer testable and isolated from JSX (G4b).
@FreekHeijting FreekHeijting changed the title Linux Mint/Ubuntu fixes + NEN 1414 symbol rendering (v1.46.0) Linux fixes + plugin-API extensions + NEN 1414 stamp fix Apr 29, 2026
ExtensionToolPalette now renders any ToolDefinition with a non-empty
`subTools` array as a tool-group: a single morphing main button (icon =
active sub-tool icon, reactive via getActiveSubTool) plus a small
chevron marker, and a pop-out sub-menu that lists the sub-tools as
icon buttons.

Behavior:
- Click main button: activates current sub-tool (default = subTools[0])
  and toggles the pop-out open/closed (B1+P1).
- Click sub-tool: setActiveSubTool + setTool + apply overrides + close.
- Click outside or press Escape: closes the menu (mode persists).
- Only one sub-menu open at a time across the app (single openGroupId
  signal).
- Pop-out side-aware: docked-left palette pops out to the right,
  docked-right pops out to the left, floating pops out to the right.

Implementation:
- New ExtToolGroupBtn component selected via <Show when={isGroup}> in
  ExtToolList; flat ExtToolBtn rendering preserved unchanged.
- side prop threaded from DockedExtPalette/FloatingExtPalette through
  ExtToolList into ExtToolGroupBtn.
- Outside-click + keydown listeners attached/detached reactively via
  createEffect, cleaned up via onCleanup.

CSS additions in styles/tool-palette.css cover .tp-btn-chevron,
.tp-submenu (with from-docked-left/from-docked-right/from-float
positioning variants), and .tp-btn-group-wrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FreekHeijting FreekHeijting changed the title Linux fixes + plugin-API extensions + NEN 1414 stamp fix Linux Mint/Ubuntu desktop fixes + plugin-API extensions + NEN 1414 stamp fix (v1.46.0) Apr 29, 2026
FreekHeijting and others added 5 commits April 29, 2026 17:08
Address review feedback on 6a01125:

- CRITICAL: Sub-menu was clipped by docked-palette overflow:hidden because
  position:absolute does not escape clipping ancestors. Fix: render the
  menu via Solid <Portal> with position:fixed and coordinates computed
  from the wrap's getBoundingClientRect() on open. Recompute on resize
  while open.
- Replace [data-tool-group-id] outside-click selector with wrapRef.contains()
  and drop the data-tool-group-id attribute (no longer needed); the
  menuRef.contains() check covers the portaled menu.
- Drop the inline style="position:relative; display:inline-flex" on the
  wrap; CSS already declares those rules. Keep position:relative on the
  wrap purely to anchor the absolutely-positioned chevron.
- Extract overridesEqual() to module scope and reuse in ExtToolBtn,
  ExtToolGroupBtn.isActive, and the per-sub subActive accessor (3 sites,
  identical null-handling preserved).
- Add aria-haspopup="menu" + aria-expanded to the main group button.
- CSS: remove .from-docked-{left,right,float} positioning rules
  (left:100% / right:100% / margin-*); positioning is now driven by
  inline style. Keep visual rules (background, border, shadow, padding,
  z-index, button size override).

ExtToolBtn behavior preserved (isActive uses the new helper but
returns identical results for all input shapes including both-null).
Verified: vite build succeeds, esbuild syntax-check passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- CustomPluginPanel.jsx: read annotProps.id reactively inside createEffect
  so the renderer re-mounts when selection swaps to another annotation of
  the same plugin-type. customPanelRender() alone stops tracking after
  the first dispatch on the same renderFn reference, leaving the panel
  stale on A→B swaps within one type.
- propertiesStore.js: document the dot-path contract on updateAnnotProp,
  including the literal "." restriction on plugin field-names and the
  no-mirror-into-annotProps behaviour, so plugin authors can rely on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Per-doc per-page generation counter, bumped on invalidateThumbnail.
  Render-completions whose generation no longer matches are discarded so
  a stale snapshot (e.g. older annotation state from an in-flight render)
  cannot overwrite a newer cache entry on rapid invalidate sequences.
- Replace empty catches in both Rust and PDF.js overlay paths with
  console.warn that includes pageNum + annotation id/type, so a single
  broken plugin annotation can no longer fail silently in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Distinguish two failure modes:

- xdg-utils not installed → return install hint (`apt install xdg-utils`)
  instead of raw NotFound IO error.
- xdg-mime non-zero exit → mention the most likely cause (missing
  /usr/share/applications/Open PDF Studio.desktop because the app was
  started from a dev build rather than the bundled .deb/.AppImage).

Both paths used to surface as opaque error strings in the settings
dialog, leaving users without a clear next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Linux/Chromium ignores background-color/color on native <select> and
falls back to the OS widget, which leaves the new-document dropdown
unreadable in dark-theme. Force appearance:none + draw the dropdown
arrow as a CSS gradient so the theme tokens (--theme-surface,
--theme-text) are honoured. Apply matching colours on <option> and
<optgroup> for consistent open-state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FreekHeijting
Copy link
Copy Markdown
Contributor Author

Quality-audit follow-up (4 commits)

After re-auditing the plugin-API and thumbnail-overlay commits we pushed earlier, four issues were worth fixing before this PR is reviewed. All four are additive and do not change any existing public contract.

Commit What Why it matters
efb329b5 fix(plugin-api): CustomPluginPanel reactivity + dot-path JSDoc Read annotProps.id reactively inside the createEffect that mounts the plugin renderer. Document the dot-path contract on updateAnnotProp. Without the reactive read, selecting annotation B after A within the same plugin-type left the panel showing A's DOM, because customPanelRender() returned the same renderFn reference and the effect did not re-run. The JSDoc clarifies the literal-. restriction so plugin authors can rely on it.
e09af662 fix(thumbnails): generation-token guard + log overlay-render errors Per-doc per-page generation counter, bumped on invalidateThumbnail. Render-completions whose generation no longer matches are discarded. Empty catches around drawAnnotation replaced with console.warn including pageNum + annotation id/type. Rapid invalidate sequences (e.g. annotation edits during a Rust render) could let a stale render result overwrite a fresher cache entry. Silent catches were also masking real plugin-render bugs in production.
e37bc4e8 fix(linux): friendlier xdg-mime errors when setting default PDF handler Detect NotFound and return an install hint (apt install xdg-utils). On non-zero exit, mention the most likely cause (Open PDF Studio.desktop not registered because the app is running from a dev build rather than the bundled .deb/.AppImage). Both paths previously surfaced as opaque error strings in the settings dialog, leaving users without a next step.
e8e94a6f fix(ui): custom <select> rendering on Linux Chromium for dark-theme Force appearance:none + CSS-drawn dropdown arrow on .new-doc-select, plus matching colours on <option>/<optgroup>. Linux/Chromium ignores background-color/color on native <select> and falls back to the OS widget, which leaves the new-document dropdown unreadable in dark-theme.

No behavioural changes to the existing public APIs introduced by the earlier commits in this PR. All fixes are scoped to a single file each. CI is rerunning on macOS, Ubuntu 22.04 and Windows.

@FreekHeijting
Copy link
Copy Markdown
Contributor Author

Ready for review @mojtabakarimi — no rush, please review whenever it suits your schedule. CI is green on all three platforms and the PR description has a structured walkthrough (sections A/B/C) to make it easier to navigate. Happy to address any feedback.

…plugins

Plugins that build analytics widgets (counts, dashboards, summaries) need
read-access to the active document's annotations without subscribing to
every mutation. Two new optional methods on the plugin API:

- api.getAnnotations() - returns the live store-array of annotations on
  the active document (each entry has a 1-indexed `page` field), or [] when
  no document is open. Read-only snapshot intended for polling consumers.
- api.getPageCount() - returns the active document's total page-count
  (>= 1) or 0 when no document is open. Combined with getCurrentPage()
  this is enough for per-page analytics.

Both are additive and feature-detectable. The Symitech inspectie-dashboard
(symitech.tools v0.2.2) needs this — without it, its count-by-type widget
read window.app.documentManager which OPPS does not expose, so counts
silently stayed at zero.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FreekHeijting
Copy link
Copy Markdown
Contributor Author

Plugin-API addition (1 commit)

Field-testing the Symitech inspectie-dashboard plugin against this branch surfaced one missing API. Adding it here keeps the PR self-contained.

Commit What Why it matters
c502d094 feat(plugin-api): expose getAnnotations + getPageCount Two new optional methods: api.getAnnotations() returns the live store-array of annotations on the active document (each entry has a 1-indexed page field), api.getPageCount() returns the total page-count. Plugins building analytics widgets (count-by-type dashboards, per-page summaries) need read-access to existing annotations without subscribing to every mutation. Without this, the only path was reading window.app.documentManager, which OPPS does not expose — so plugin-side counts silently stayed at zero. Both methods are additive and feature-detectable.

CI is rerunning; expect the same green result as before.

FreekHeijting and others added 2 commits April 29, 2026 20:54
Wraps buildAnnotationProps call met try/finally; flag wordt gelezen door
Symitech SP2 plugin (v0.2.4) om counter-allocators te skippen tijdens
de per-pointer-move drag-create preview-render-loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…allback

Plugin-annotations (Symitech SP2: scheur, doorvoer-polyline-closed,
doorvoer-line-contour, niet-onderzocht-polyline-closed, vloer-contour)
krijgen automatisch per-vertex polyline_node-handles zodra ze een
points-array exposeren. Vereist voor SP2 v0.2.4 vertex-edit-gizmos
(Task 4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FreekHeijting and others added 9 commits April 29, 2026 20:58
…pplyResize

Plugin-annotations met points-array reageren op polyline_node_<i>-handle
drag identiek aan builtin polyline-case. Inclusief shift-snap (45 graden) en
auto bbox-recalc voor types die x/y/width/height tracken. Vereist voor
SP2 v0.2.4 vertex-edit-gizmos (Task 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e-snap

Bij e.shiftKey + actieve tool's plugin handler met snapHook-functie:
nieuwe vertex (en preview-vertex) snapt naar laatst-gecommitteerde
vertex via handler.snapHook(last.x, last.y, cur.x, cur.y). Toegepast
in onPointerDown (commit) en onPointerMove (preview). Voor SP2 v0.2.4
shift-snap met 0/30/45/60/90-per-kwadrant set (Tasks 7-9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tijdens polyline-drawing: als cursor < 8/scale screen-px van eerste
vertex (en >=3 vertices al gezet) → snap cursor naar eerste vertex +
toon cyan cirkel-indicator. Klik in deze state = sluit polygon
(state.closed = true wordt aan plugin's typeHandler.create doorgegeven
via _finishPolyline). Voor SP2 v0.2.4 close-contour-snap (Task 10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ypes

Plugin-annotations (e.g. symitech.schade, symitech.scheur,
symitech.vloer-contour) couldn't be moved with the hand-tool because the
applyMove switch had no default branch — drag-state was set up correctly
but the actual coordinate-shift was a no-op for any unknown type.

Add a default case that shifts whichever well-known position-bearing
fields are present on the annotation: x/y, startX/Y + endX/Y, points[],
path[]. Plugins that need custom semantics can still opt out via
annotation.locked = true (handled at the top of the function).

This unblocks the entire Symitech plugin's interaction model: schade-
markers, reeks-markers, scheur-polylines, doorvoer/niet-onderzocht
shapes and vloer-contour all become draggable without each plugin having
to monkey-patch transforms.js or register its own move-handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…case

Plugin point-marker types (symitech.schade, symitech.reeks,
symitech.doorvoer.point-marker) store their position as
`annotation.at = {x, y}` rather than top-level x/y. Previous
default-case only shifted top-level x/y/startX/etc, so dragging
these annotations with the hand-tool silently no-op'd.

Also handle annotation.cx/cy for circle/ellipse-shaped plugin types.
Plugin rect-area + oval-area types (symitech.doorvoer.rect-area,
symitech.doorvoer.oval-area, symitech.niet-onderzocht.rect-area,
symitech.niet-onderzocht.oval-area) slaan geometry op als {x, y, w, h}
i.p.v. builtin width/height. Default-case in handles.js zag dat niet
en gaf alleen polyline-node handles als er points-array was, dus
rect/oval-area types kregen GEEN handles na placement.

Fix:
- handles.js: default-case detecteert {x,y,w,h} en emit 4 corner +
  4 edge handles.
- transforms.js applyResize: default-case interpreteert TL/TR/BL/BR/T/
  B/L/R en muteert annotation.w/h met dezelfde logica als de built-in
  box-case (incl. min-10 size-guard).

User-eis: "vormen na plaatsing controlpoints geven om grootte te
tweaken", uitgesloten van resize blijven point-marker types
(symitech.schade, symitech.reeks, symitech.doorvoer.point-marker)
omdat die via annotation.at niet via {x,y,w,h} of points werken.
Plugin polyline-handlers krijgen nu de volledige polylinePoints array
mee als 5e argument van snapHook. Maakt segment-relatieve snap mogelijk
(perpendicular/parallel op het laatste afgesloten segment) zodat
rect/cube tekenen onder een willekeurige starthoek met shift haaks
gehouden blijft. Pre-existing handlers die alleen de eerste 4 args
gebruiken blijven functioneel: 5e arg is optioneel.
User: "als ik een scheur teken kost het 1 rechtermuisklik om af te
sluiten maar dan opent direct het selectiemenu". Race tussen
pointerdown(button=2) en contextmenu-event:
- pointerdown finished polyline en zet isDrawingPolyline=false
- daarna fired contextmenu, ziet drawing-flag false en opent menu

Fix: polyline-tool zet `state._suppressNextContextmenu = true` zodra
het rechtermuisklik-finish afhandelt. canvas-contextmenu-handler ziet
flag, slikt het volgende contextmenu in (preventDefault) en wist de
flag. 2e rechtermuisklik (na sluiten) opent normaal het selectie-menu.
Built-in polyline auto-resette naar select-tool na _finishPolyline; voor
plugin-types met drawMode='polyline' (symitech.scheur, vloer-contour,
doorvoer-polyline-closed/line-contour, niet-onderzocht.polyline-closed)
hindert dat de workflow: gebruiker wil meerdere scheuren/contouren
achter elkaar tekenen zonder elke keer de tool opnieuw te kiezen.

Fix: detecteer of de huidige tool een plugin-handler met drawMode=
'polyline' is; sla in dat geval de auto-reset over zodat de tool actief
blijft.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants