diff --git a/open-pdf-studio/js/annotations/stavenreeks.js b/open-pdf-studio/js/annotations/stavenreeks.js index c05cacef..e91772eb 100644 --- a/open-pdf-studio/js/annotations/stavenreeks.js +++ b/open-pdf-studio/js/annotations/stavenreeks.js @@ -541,6 +541,7 @@ export function labelLayout(count, diameter, fontSize, measure = approxTextWidth * @param {object} [opts] * @param {(text:string,fontSize:number)=>number} [opts.measureText] * Echte tekstbreedte-meter (canvas of PDF-fontmetriek). Default: schatting. + * @param {number} [opts.pxPerMm] Pagina-pixels per werkelijke millimeter. * @returns {{ * params: object, frame: object, line: object, * legs: Array, dots: Array, label: object, diff --git a/open-pdf-studio/js/bridge.ts b/open-pdf-studio/js/bridge.ts index dcdd39a6..b8dbe169 100644 --- a/open-pdf-studio/js/bridge.ts +++ b/open-pdf-studio/js/bridge.ts @@ -139,6 +139,17 @@ export { stavenreeksInputActive, } from './solid/stores/stavenreeksInputStore.js'; +// ============= PARAMETRISCH LABEL INLINE INVOER ============= +export { + showParametricLabelInput, + hideParametricLabelInput, + parametricLabelInputActive, +} from './solid/stores/parametricLabelInputStore.js'; + +export { + validateSymbolParams, +} from './solid/stores/parametricSymbolStore.js'; + // ============= PDF TEXT EDITOR ============= export { showPdfTextEditor, diff --git a/open-pdf-studio/js/core/constants.ts b/open-pdf-studio/js/core/constants.ts index 640c82bb..9282d7d6 100644 --- a/open-pdf-studio/js/core/constants.ts +++ b/open-pdf-studio/js/core/constants.ts @@ -291,6 +291,7 @@ export const DEFAULT_PREFERENCES: Preferences = { symbolPaletteFloatY: 150, customSymbolGroups: [], disabledSymbolGroups: [], + parametricSymbolDefaults: {}, // User-edited symbol type geometry overrides, keyed by a hash of the // original symbol SVG. Each entry: { svg, name }. Applied whenever a stamp // of that type is placed or re-rendered. diff --git a/open-pdf-studio/js/solid/components/DialogHost.jsx b/open-pdf-studio/js/solid/components/DialogHost.jsx index 86c83afd..2c1140a3 100644 --- a/open-pdf-studio/js/solid/components/DialogHost.jsx +++ b/open-pdf-studio/js/solid/components/DialogHost.jsx @@ -39,6 +39,7 @@ import TitleBlockDialog from './dialogs/TitleBlockDialog.jsx'; import CompareDialog from './compare/CompareDialog.jsx'; import TextEditOverlay from './TextEditOverlay.jsx'; import StavenreeksInlineEditor from './StavenreeksInlineEditor.jsx'; +import ParametricLabelInlineEditor from './ParametricLabelInlineEditor.jsx'; import PdfTextEditOverlay from './PdfTextEditOverlay.jsx'; import StickyNotePopupHost from './StickyNotePopup.jsx'; import ParametricSymbolPicker from './dialogs/ParametricSymbolPicker.jsx'; @@ -98,6 +99,7 @@ export default function DialogHost() { + diff --git a/open-pdf-studio/js/solid/components/ParametricLabelInlineEditor.jsx b/open-pdf-studio/js/solid/components/ParametricLabelInlineEditor.jsx new file mode 100644 index 00000000..61f59d04 --- /dev/null +++ b/open-pdf-studio/js/solid/components/ParametricLabelInlineEditor.jsx @@ -0,0 +1,137 @@ +import { For, Show, createEffect, onCleanup } from 'solid-js'; +import { + active, anchor, setAnchor, fields, values, setFieldValue, + onCommit, onCancel, locator, hideParametricLabelInput, + returnFocusTarget as requestedReturnFocusTarget, +} from '../stores/parametricLabelInputStore.js'; +import { createOutsideCommitController } from './parametric-label-outside-events.js'; +import { + captureParametricLabelReturnFocus, + restoreParametricLabelFocus, +} from './parametric-label-focus.js'; + +export default function ParametricLabelInlineEditor() { + let rootRef; + let firstInputRef; + let capturedReturnFocusTarget = null; + + const scheduleFocusRestore = () => { + const target = capturedReturnFocusTarget; + if (!target) return; + capturedReturnFocusTarget = null; + queueMicrotask(() => restoreParametricLabelFocus(target)); + }; + + const commit = () => { + if (!active()) return; + const callback = onCommit(); + const nextValues = { ...values() }; + hideParametricLabelInput(); + if (callback) callback(nextValues); + scheduleFocusRestore(); + }; + + const cancel = () => { + if (!active()) return; + const callback = onCancel(); + hideParametricLabelInput(); + if (callback) callback(); + scheduleFocusRestore(); + }; + + const handleKeyDown = (e) => { + e.stopPropagation(); + if (e.key === 'Enter') { + e.preventDefault(); + commit(); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + cancel(); + } + }; + + createEffect(() => { + if (!active()) return; + + capturedReturnFocusTarget = captureParametricLabelReturnFocus( + document, + requestedReturnFocusTarget(), + ); + queueMicrotask(() => { + firstInputRef?.focus(); + firstInputRef?.select(); + }); + + const outsideController = createOutsideCommitController({ + isActive: active, + commit, + isCanvasTarget: (target) => + target instanceof Element + && !!target.closest('#annotation-canvas, .annotation-canvas'), + }); + const onOutsidePointerDown = (event) => + outsideController.pointerDown(event, rootRef); + const onOutsideClick = (event) => + outsideController.click(event, rootRef); + document.addEventListener('pointerdown', onOutsidePointerDown, true); + window.addEventListener('click', onOutsideClick); + + let raf = 0; + const tick = () => { + if (!active()) return; + const locate = locator(); + if (typeof locate === 'function') { + const pos = locate(); + if (!pos) { + cancel(); + return; + } + const current = anchor(); + if (pos.left !== current.left || pos.top !== current.top) setAnchor(pos); + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + + onCleanup(() => { + outsideController.reset(); + document.removeEventListener('pointerdown', onOutsidePointerDown, true); + window.removeEventListener('click', onOutsideClick); + if (raf) cancelAnimationFrame(raf); + scheduleFocusRestore(); + }); + }); + + return ( + +
+ {(field, index) => ( + + )} +
+
+ ); +} diff --git a/open-pdf-studio/js/solid/components/SymbolPalette.jsx b/open-pdf-studio/js/solid/components/SymbolPalette.jsx index 4019ad50..89a4a833 100644 --- a/open-pdf-studio/js/solid/components/SymbolPalette.jsx +++ b/open-pdf-studio/js/solid/components/SymbolPalette.jsx @@ -25,6 +25,7 @@ import { import { registerPaletteDock, unregisterPaletteDock } from '../stores/paletteOrder.js'; import { ifcCategoryForSymbol } from '../data/ifcCategoryMap.js'; import { SYMBOL_STAMP_DEFAULT_SIZE } from '../../annotations/stamp-defaults.js'; +import { setPendingSymbolId } from '../stores/parametricSymbolStore.js'; const DOCK_SNAP = 60; @@ -89,10 +90,8 @@ function selectSymbol(symbol) { // click on the page goes through the parametricSymbol tool and the params // stay editable in the properties panel. if (symbol.parametricId) { - import('../stores/parametricSymbolStore.js').then(m => { - m.setPendingSymbolId(symbol.parametricId); - setTool('parametricSymbol'); - }); + setPendingSymbolId(symbol.parametricId); + setTool('parametricSymbol'); return; } // Generic tool entries: palette items that simply activate a drawing tool diff --git a/open-pdf-studio/js/solid/components/parametric-label-focus.js b/open-pdf-studio/js/solid/components/parametric-label-focus.js new file mode 100644 index 00000000..de630279 --- /dev/null +++ b/open-pdf-studio/js/solid/components/parametric-label-focus.js @@ -0,0 +1,46 @@ +function makeProgrammaticallyFocusable(target) { + if ( + target + && typeof target.hasAttribute === 'function' + && typeof target.setAttribute === 'function' + && !target.hasAttribute('tabindex') + ) { + target.setAttribute('tabindex', '-1'); + } + return target; +} + +export function captureParametricLabelReturnFocus( + documentRef = document, + preferredCanvas = null, +) { + const activeElement = documentRef?.activeElement; + if ( + activeElement + && activeElement !== documentRef?.body + && activeElement !== documentRef?.documentElement + && typeof activeElement.focus === 'function' + ) { + return activeElement; + } + const canvas = preferredCanvas?.isConnected === false + ? null + : preferredCanvas; + return makeProgrammaticallyFocusable( + canvas || documentRef?.querySelector?.('#annotation-canvas, .annotation-canvas') || null, + ); +} + +export function restoreParametricLabelFocus(target) { + if (!target || target.isConnected === false || typeof target.focus !== 'function') { + return false; + } + try { + target.focus({ preventScroll: true }); + } catch (_) { + target.focus(); + } + const documentRef = target.ownerDocument + || (typeof document !== 'undefined' ? document : null); + return !documentRef?.activeElement || documentRef.activeElement === target; +} diff --git a/open-pdf-studio/js/solid/components/parametric-label-outside-events.js b/open-pdf-studio/js/solid/components/parametric-label-outside-events.js new file mode 100644 index 00000000..4d21c621 --- /dev/null +++ b/open-pdf-studio/js/solid/components/parametric-label-outside-events.js @@ -0,0 +1,33 @@ +export function createOutsideCommitController({ + isActive, + commit, + isCanvasTarget, +}) { + let pendingOutsideClick = false; + + const isInside = (target, root) => + !!(root && target != null && root.contains(target)); + + return { + pointerDown(event, root) { + pendingOutsideClick = false; + if (isInside(event.target, root)) return; + if (isCanvasTarget(event.target)) { + if (isActive()) commit(); + return; + } + pendingOutsideClick = true; + }, + + click(event, root) { + if (!pendingOutsideClick) return; + pendingOutsideClick = false; + if (isInside(event.target, root) || !isActive()) return; + commit(); + }, + + reset() { + pendingOutsideClick = false; + }, + }; +} diff --git a/open-pdf-studio/js/solid/stores/parametricLabelInputStore.js b/open-pdf-studio/js/solid/stores/parametricLabelInputStore.js new file mode 100644 index 00000000..0565294d --- /dev/null +++ b/open-pdf-studio/js/solid/stores/parametricLabelInputStore.js @@ -0,0 +1,42 @@ +import { createSignal } from 'solid-js'; + +const [active, setActive] = createSignal(false); +const [anchor, setAnchor] = createSignal({ left: 0, top: 0 }); +const [fields, setFields] = createSignal([]); +const [values, setValues] = createSignal({}); +const [onCommit, setOnCommit] = createSignal(null); +const [onCancel, setOnCancel] = createSignal(null); +const [locator, setLocator] = createSignal(null); +const [returnFocusTarget, setReturnFocusTarget] = createSignal(null); + +export function showParametricLabelInput(options = {}) { + setAnchor(options.anchor || { left: 0, top: 0 }); + setFields(Array.isArray(options.fields) ? options.fields : []); + setValues({ ...(options.values || {}) }); + setOnCommit(() => (typeof options.commit === 'function' ? options.commit : null)); + setOnCancel(() => (typeof options.cancel === 'function' ? options.cancel : null)); + setLocator(() => (typeof options.locate === 'function' ? options.locate : null)); + setReturnFocusTarget(options.returnFocusTarget || null); + setActive(true); +} + +export function hideParametricLabelInput() { + setActive(false); + setLocator(() => null); + setOnCommit(() => null); + setOnCancel(() => null); + setReturnFocusTarget(null); +} + +export function setFieldValue(key, value) { + setValues((current) => ({ ...current, [key]: value })); +} + +export function parametricLabelInputActive() { + return active(); +} + +export { + active, anchor, setAnchor, + fields, values, onCommit, onCancel, locator, returnFocusTarget, +}; diff --git a/open-pdf-studio/js/solid/stores/parametricSymbolStore.js b/open-pdf-studio/js/solid/stores/parametricSymbolStore.js index 3004db3f..b8e2a650 100644 --- a/open-pdf-studio/js/solid/stores/parametricSymbolStore.js +++ b/open-pdf-studio/js/solid/stores/parametricSymbolStore.js @@ -1,17 +1,67 @@ // Tracks the current parametric symbol selection used by the // parametricSymbol tool when placing a new annotation. import { createSignal } from 'solid-js'; -import { listTemplates } from '../../symbols/registry.js'; +import { state } from '../../core/state.js'; +import { getTemplate, listTemplates, defaultParams } from '../../symbols/registry.js'; -const [pendingSymbolId, setPendingSymbolId] = createSignal('door'); +const [pendingSymbolId, setPendingSymbolIdSignal] = createSignal('door'); +const [pendingParams, setPendingParamsSignal] = createSignal({}); const [pickerOpen, setPickerOpen] = createSignal(false); +export function validateSymbolParams(symbolId, values = {}) { + const template = getTemplate(symbolId); + const defaults = defaultParams(template); + if (!template) return {}; + const result = { ...defaults }; + for (const def of template.params || []) { + const raw = values[def.key]; + if (raw === undefined) continue; + if (def.type === 'number') { + const number = Number(raw); + if (!Number.isFinite(number)) continue; + result[def.key] = Math.min(def.max ?? Infinity, Math.max(def.min ?? -Infinity, number)); + } else if (def.type === 'boolean') { + result[def.key] = raw === true; + } else if (def.type === 'enum') { + if ((def.options || []).some((option) => option.value === raw)) result[def.key] = raw; + } else { + result[def.key] = String(raw); + } + } + return result; +} + +export function resolveSymbolParams(symbolId) { + return validateSymbolParams( + symbolId, + state.preferences.parametricSymbolDefaults?.[symbolId] || {}, + ); +} + +export function setPendingSymbolId(symbolId) { + setPendingSymbolIdSignal(symbolId); + setPendingParamsSignal(resolveSymbolParams(symbolId)); +} + +export function setPendingParams(values) { + const symbolId = pendingSymbolId(); + const validated = validateSymbolParams(symbolId, values); + setPendingParamsSignal(validated); + state.preferences.parametricSymbolDefaults = { + ...(state.preferences.parametricSymbolDefaults || {}), + [symbolId]: validated, + }; + import('../../core/preferences.js').then((module) => module.savePreferences()).catch(() => {}); +} + +setPendingSymbolId('door'); + function getAvailableTemplates() { return listTemplates(); } export { - pendingSymbolId, setPendingSymbolId, + pendingSymbolId, pendingParams, pickerOpen, setPickerOpen, getAvailableTemplates, }; diff --git a/open-pdf-studio/js/solid/stores/propertiesStore.js b/open-pdf-studio/js/solid/stores/propertiesStore.js index ff752be8..9c3fef50 100644 --- a/open-pdf-studio/js/solid/stores/propertiesStore.js +++ b/open-pdf-studio/js/solid/stores/propertiesStore.js @@ -21,6 +21,7 @@ import { syncDocScale } from '../../annotations/scale-bar.js'; import { STAVENREEKS_DEFAULTS } from '../../annotations/stavenreeks.js'; import { recalculateAllMeasurements, calculateArea, calculatePerimeter, calculateDistance, formatMeasurement, formatDimensionText, getMeasureScale } from '../../annotations/measurement.js'; import { applyTemplateRealSize } from '../../symbols/real-size.js'; +import { pendingParams, setPendingParams } from './parametricSymbolStore.js'; // Types whose single 'color' control IS their stroke colour and which render // via `strokeColor || color`. For these, the 'color' control must mirror onto @@ -923,6 +924,15 @@ export function updateAnnotProp(key, value) { if (!currentAnnotation) return; + if (currentAnnotation.id === '__tool-defaults__' + && currentAnnotation.type === 'parametricSymbol' + && key === 'params') { + setPendingParams(value); + currentAnnotation.params = pendingParams(); + setAnnotProps('params', currentAnnotation.params); + return; + } + // Tool-defaults mode: user is editing the synthetic annotation that // showToolDefaults() created. Route writes to state.preferences via // setAsDefaultStyle so the NEXT annotation drawn picks up the changes, @@ -1454,6 +1464,12 @@ export function getCurrentAnnotation() { return currentAnnotation; } +export function hideToolDefaults() { + if (currentAnnotation?.id !== '__tool-defaults__') return false; + storeHideProperties(); + return true; +} + // Show the properties panel populated with the current style defaults for // the active drawing tool. Builds a SYNTHETIC annotation tagged with id // '__tool-defaults__' so the rest of the panel pipeline treats it like a @@ -1461,7 +1477,7 @@ export function getCurrentAnnotation() { // Edits made by the user via panel inputs update the synthetic object for // visual feedback; persistent default changes still flow through the // Format ribbon's `setAsDefaultStyle` path. -export async function showToolDefaults(toolName) { +export async function showToolDefaults(toolName, overrides = {}, shouldShow = null) { if (!toolName) return; // Map tool name → annotation type. Most are 1:1; exceptions go here. const TOOL_TO_TYPE = { @@ -1514,7 +1530,10 @@ export async function showToolDefaults(toolName) { // Non-fatal — synthetic will just show the bare defaults above. } + if (typeof shouldShow === 'function' && !shouldShow()) return false; + Object.assign(synthetic, overrides); storeShowProperties(synthetic); + return true; } export { diff --git a/open-pdf-studio/js/symbols/editable-labels.js b/open-pdf-studio/js/symbols/editable-labels.js new file mode 100644 index 00000000..2b196cfd --- /dev/null +++ b/open-pdf-studio/js/symbols/editable-labels.js @@ -0,0 +1,24 @@ +import { getTemplate } from './registry.js'; + +export function toLocalPoint(annotation, x, y) { + const angle = -(Number(annotation.rotation) || 0) * Math.PI / 180; + const cx = annotation.x + annotation.width / 2; + const cy = annotation.y + annotation.height / 2; + const dx = x - cx; + const dy = y - cy; + return { + x: cx + dx * Math.cos(angle) - dy * Math.sin(angle), + y: cy + dx * Math.sin(angle) + dy * Math.cos(angle), + }; +} + +export function findEditableLabel(annotation, x, y) { + if (annotation?.type !== 'parametricSymbol') return null; + const template = getTemplate(annotation.symbolId); + if (typeof template?.editableLabels !== 'function') return null; + const point = toLocalPoint(annotation, x, y); + const labels = template.editableLabels(annotation.params || {}, annotation); + return labels.find(({ rect }) => + point.x >= rect.x && point.x <= rect.x + rect.width + && point.y >= rect.y && point.y <= rect.y + rect.height) || null; +} diff --git a/open-pdf-studio/js/symbols/templates/wapening-lijn.js b/open-pdf-studio/js/symbols/templates/wapening-lijn.js index ae1ca319..0df1678f 100644 --- a/open-pdf-studio/js/symbols/templates/wapening-lijn.js +++ b/open-pdf-studio/js/symbols/templates/wapening-lijn.js @@ -55,11 +55,10 @@ function lineLayout(params, bbox, centerLine = false) { }; } -function rebarLabelCommands(params, layout, isNet, bbox) { +function rebarLabelLayout(params, layout, isNet, bbox) { const font = layout.textSize; const gap = font * 0.20; const signWidth = font * 0.68; - const signRadius = font * 0.22; const left = isNet ? '' : String(integer(params.aantal, 3, 1)); const diameter = compactNumber(positiveNumber(params.diameter, 8, 1)); const lengte = compactNumber(positiveNumber(params.lengte, 1600, 1)); @@ -71,10 +70,23 @@ function rebarLabelCommands(params, layout, isNet, bbox) { const leftGap = left ? gap : 0; const totalWidth = leftWidth + leftGap + signWidth + gap + rightWidth; const desiredX = layout.markerX - totalWidth / 2; - let cursor = Math.max( + const x = Math.max( bbox.x, Math.min(desiredX, bbox.x + Math.max(0, bbox.width - totalWidth)), ); + return { + font, gap, signWidth, signRadius: font * 0.22, + left, right, leftWidth, rightWidth, leftGap, totalWidth, x, + }; +} + +function rebarLabelCommands(params, layout, isNet, bbox) { + const metrics = rebarLabelLayout(params, layout, isNet, bbox); + const { + font, gap, signWidth, signRadius, + left, right, leftWidth, rightWidth, leftGap, + } = metrics; + let cursor = metrics.x; const commands = []; if (left) { @@ -108,6 +120,47 @@ function rebarLabelCommands(params, layout, isNet, bbox) { return commands; } +function editableLineLabels(params, bbox, isNet, centerLine) { + const layout = lineLayout(params, bbox, centerLine); + const metrics = rebarLabelLayout(params, layout, isNet, bbox); + return [{ + id: 'label', + rect: { + x: metrics.x, + y: layout.textY - metrics.font * 0.6, + width: metrics.totalWidth, + height: metrics.font * 1.2, + }, + fields: isNet + ? ['diameter', 'afstand', 'lengte'] + : ['aantal', 'diameter', 'lengte'], + }]; +} + +function markerCommands(params, layout, bbox, enabled) { + const count = enabled + ? Math.min(4, Math.max(1, Math.round(Number(params.markerAantal) || 1))) + : 1; + const gap = layout.markerHalfWidth * 0.35; + const step = layout.markerHalfWidth * 2 + gap; + const groupWidth = step * (count - 1) + layout.markerHalfWidth * 2; + const center = Math.max( + bbox.x + groupWidth / 2, + Math.min(layout.markerX, bbox.x + bbox.width - groupWidth / 2), + ); + return Array.from({ length: count }, (_, index) => { + const markerX = center + (index - (count - 1) / 2) * step; + return { + kind: 'polyline', close: true, fill: true, role: 'marker', + points: [ + { x: markerX - layout.markerHalfWidth, y: layout.markerBaseY }, + { x: markerX, y: layout.markerTipY }, + { x: markerX + layout.markerHalfWidth, y: layout.markerBaseY }, + ], + }; + }); +} + function renderLine(params, bbox, isNet, centerLine = false) { const layout = lineLayout(params, bbox, centerLine); return [ @@ -118,17 +171,7 @@ function renderLine(params, bbox, isNet, centerLine = false) { x2: bbox.x + bbox.width, y2: layout.lineY, }, - { - kind: 'polyline', - close: true, - fill: true, - role: 'marker', - points: [ - { x: layout.markerX - layout.markerHalfWidth, y: layout.markerBaseY }, - { x: layout.markerX, y: layout.markerTipY }, - { x: layout.markerX + layout.markerHalfWidth, y: layout.markerBaseY }, - ], - }, + ...markerCommands(params, layout, bbox, !isNet), ...rebarLabelCommands(params, layout, isNet, bbox), ]; } @@ -169,6 +212,10 @@ export const wapeningsstaafTemplate = { { key: 'aantal', label: 'Aantal', labelEn: 'Quantity', type: 'number', default: 3, min: 1, step: 1 }, { key: 'diameter', label: 'Diameter (mm)', labelEn: 'Diameter (mm)', type: 'number', default: 8, min: 1, step: 1 }, { key: 'lengte', label: 'Lengte (mm)', labelEn: 'Length (mm)', type: 'number', default: 1600, min: 1, step: 10 }, + { + key: 'markerAantal', label: 'Aantal vlaggen', labelEn: 'Marker count', + type: 'number', default: 1, min: 1, max: 4, step: 1, + }, ...markerParams, ], layout(params, bbox) { @@ -177,6 +224,9 @@ export const wapeningsstaafTemplate = { render(params, bbox) { return renderLine(params || {}, bbox, false, true); }, + editableLabels(params, bbox) { + return editableLineLabels(params || {}, bbox, false, true); + }, realSizeMm(params) { return { width: positiveNumber(params?.lengte, 1600, 1), height: 240 }; }, @@ -200,6 +250,9 @@ export const netwapeningTemplate = { render(params, bbox) { return renderLine(params || {}, bbox, true); }, + editableLabels(params, bbox) { + return editableLineLabels(params || {}, bbox, true, false); + }, realSizeMm(params) { return { width: positiveNumber(params?.lengte, 1600, 1), height: 240 }; }, diff --git a/open-pdf-studio/js/symbols/templates/wapeningskorf.js b/open-pdf-studio/js/symbols/templates/wapeningskorf.js index 2ce3a232..5e3b5f1e 100644 --- a/open-pdf-studio/js/symbols/templates/wapeningskorf.js +++ b/open-pdf-studio/js/symbols/templates/wapeningskorf.js @@ -295,6 +295,78 @@ export const wapeningskorfTemplate = { ]; }, + editableLabels(params, bbox) { + const L = layoutMm(params); + const W = L.footprint.width; + const H = L.footprint.height; + const S = Math.min((bbox.width || 1) / W, (bbox.height || 1) / H); + const ox = bbox.x + ((bbox.width || 0) - W * S) / 2; + const oy = bbox.y + ((bbox.height || 0) - H * S) / 2; + const textRect = (x, y, width, font = L.font) => ({ + x: ox + x * S, + y: oy + (y - font * 0.6) * S, + width: Math.max(font * 0.5, width) * S, + height: font * 1.2 * S, + }); + const labels = [ + { + id: 'boven', + fields: ['bovenAantal', 'bovenDiameter'], + rect: textRect( + L.labels.boven.x, L.labels.boven.y, + _barLabelWidth(L.bovenAantal, L.bovenDiameter, L.font), + ), + }, + { + id: 'zij', + fields: ['zijAantal', 'zijDiameter'], + rect: textRect( + L.labels.zij.x, L.labels.zij.y, + _barLabelWidth(L.zijAantal, L.zijDiameter, L.font), + ), + }, + { + id: 'onder', + fields: ['onderAantal', 'onderDiameter'], + rect: textRect( + L.labels.onder.x, L.labels.onder.y, + _barLabelWidth(L.onderAantal, L.onderDiameter, L.font), + ), + }, + { + id: 'beugel', + fields: ['beugelDiameter', 'beugelAfstand'], + rect: textRect( + L.labels.beugel.x, L.labels.beugel.y, + _stirrupLabelWidth( + L.prefix, L.beugelDiameter, L.beugelAfstand, L.font, + ), + ), + }, + { + id: 'naam', + fields: ['naam'], + rect: { + ...textRect( + L.caption.x, L.caption.y, + approxTextWidth(L.naam, L.font * 1.1), L.font * 1.1, + ), + x: ox + (L.caption.x + - approxTextWidth(L.naam, L.font * 1.1) / 2) * S, + }, + }, + ]; + const toonLabels = params?.toonLabels !== false; + return labels.filter(({ id }) => { + if (id === 'naam') return !!L.naam; + if (!toonLabels) return false; + if (id === 'boven') return L.bovenAantal > 0; + if (id === 'zij') return L.zijAantal > 0; + if (id === 'onder') return L.onderAantal > 0; + return true; + }); + }, + render(params, bbox) { const L = layoutMm(params); const W = L.footprint.width; diff --git a/open-pdf-studio/js/tools/annotation-creators.js b/open-pdf-studio/js/tools/annotation-creators.js index c09e86a9..a6e9554b 100644 --- a/open-pdf-studio/js/tools/annotation-creators.js +++ b/open-pdf-studio/js/tools/annotation-creators.js @@ -5,10 +5,10 @@ import { snapAngle } from '../utils/helpers.js'; import { calculateDistance, calculateArea, calculatePerimeter, formatMeasurement, snapDistanceTo10 } from '../annotations/measurement.js'; import { getAnnotationType } from '../plugins/annotation-type-registry.js'; import { applyDynamicScaling } from '../annotations/dynamic-scaling.js'; -import { getTemplate, defaultParams } from '../symbols/registry.js'; +import { getTemplate } from '../symbols/registry.js'; import { pxPerMmAt } from '../symbols/real-size.js'; import { syncTwoPointGeometry, syncTwoPointLengthParam } from '../symbols/two-point.js'; -import { pendingSymbolId } from '../solid/stores/parametricSymbolStore.js'; +import { pendingParams, pendingSymbolId } from '../solid/stores/parametricSymbolStore.js'; import { activeCountCategory as _activeCountCategory, nextCountNumber as _nextCountNumber } from '../solid/stores/countStore.js'; import { ifcCategoryForParametric, ifcCategoryForAnnotationType } from '../solid/data/ifcCategoryMap.js'; import { STAVENREEKS_DEFAULTS } from '../annotations/stavenreeks.js'; @@ -348,8 +348,9 @@ export function buildAnnotationProps(tool, startX, startY, endX, endY, e) { case 'parametricSymbol': { const symbolId = pendingSymbolId() || 'door'; const template = getTemplate(symbolId); + if (!template) return null; const page = getActiveDocument()?.currentPage || 1; - const params = template ? defaultParams(template) : {}; + const params = pendingParams(); if (template?.placement === 'two-point') { const snappedEnd = snap(startX, startY, endX, endY); diff --git a/open-pdf-studio/js/tools/manager.js b/open-pdf-studio/js/tools/manager.js index 8c66c644..06d6bcdd 100644 --- a/open-pdf-studio/js/tools/manager.js +++ b/open-pdf-studio/js/tools/manager.js @@ -8,9 +8,11 @@ import { getTool } from './tool-registry.js'; import { buildToolContext, resolvePointerCoords } from './tool-context.js'; import { findAnnotationAt } from '../annotations/geometry.js'; import { findHandleAt } from '../annotations/handles.js'; +import { cancelParametricSymbolInput } from './parametric-symbol-editing.js'; // Tools that are always allowed (view-only, non-modifying) const READONLY_ALLOWED_TOOLS = new Set(['select', 'hand']); +let toolDefaultsRequestToken = 0; // Get cursor for a given tool export function getCursorForTool(tool = state.currentTool) { @@ -221,9 +223,11 @@ export function setTool(tool) { } } - // Een openstaande inline stavenreeks-invoer hoort niet te blijven zweven - // wanneer de gebruiker van gereedschap wisselt. + // Openstaande inline invoer hoort niet te blijven zweven wanneer de + // gebruiker van gereedschap wisselt. Parametrische invoer moet synchroon + // sluiten: de window-click van de editor volgt pas na deze toolhandler. import('./stavenreeks-editing.js').then(m => m.cancelStavenreeksInput()).catch(() => {}); + cancelParametricSymbolInput(); // Deactivate PDF text editing when switching away if (state.currentTool === 'editText' && tool !== 'editText') { @@ -231,6 +235,9 @@ export function setTool(tool) { } state.currentTool = tool; + const defaultsRequest = ++toolDefaultsRequestToken; + const isCurrentDefaultsRequest = () => + defaultsRequest === toolDefaultsRequestToken && state.currentTool === tool; // Don't clear toolOverrides when switching TO stamp or wall — the // SymbolPalette sets them (stamp SVG / wall material+dikte) before setTool. if (tool !== 'stamp' && tool !== 'wall') { @@ -290,21 +297,39 @@ export function setTool(tool) { // (synthetic annotation) so the user can see them BEFORE drawing. // Otherwise hide the panel (e.g. hand, editText). Select keeps its // own state (selected annotation or none). - if (tool !== 'select') { + if (tool === 'select') { + import('../solid/stores/propertiesStore.js') + .then((propStore) => { + if (isCurrentDefaultsRequest()) propStore.hideToolDefaults?.(); + }) + .catch(() => {}); + } else { (async () => { try { + if (tool === 'parametricSymbol') { + const symbolStore = await import('../solid/stores/parametricSymbolStore.js'); + const propStore = await import('../solid/stores/propertiesStore.js'); + if (!isCurrentDefaultsRequest()) return; + await propStore.showToolDefaults(tool, { + symbolId: symbolStore.pendingSymbolId(), + params: symbolStore.pendingParams(), + }, isCurrentDefaultsRequest); + return; + } const prefMod = await import('../core/preferences.js'); + if (!isCurrentDefaultsRequest()) return; const hasStyle = prefMod && typeof prefMod.getStyleMapping === 'function' && prefMod.getStyleMapping(tool) != null; if (hasStyle) { const propMod = await import('../solid/stores/propertiesStore.js'); + if (!isCurrentDefaultsRequest()) return; if (propMod && typeof propMod.showToolDefaults === 'function') { - await propMod.showToolDefaults(tool); + await propMod.showToolDefaults(tool, {}, isCurrentDefaultsRequest); return; } } } catch (_) { /* fall through to hide */ } - hideProperties(); + if (isCurrentDefaultsRequest()) hideProperties(); })(); } diff --git a/open-pdf-studio/js/tools/parametric-symbol-editing.js b/open-pdf-studio/js/tools/parametric-symbol-editing.js new file mode 100644 index 00000000..f381284c --- /dev/null +++ b/open-pdf-studio/js/tools/parametric-symbol-editing.js @@ -0,0 +1,124 @@ +import { getActiveDocument } from '../core/state.js'; +import { annotationCanvas } from '../ui/dom-elements.js'; +import { viewport as viewportState } from '../pdf/pdf-viewport.js'; +import { getTemplate } from '../symbols/registry.js'; +import { findEditableLabel } from '../symbols/editable-labels.js'; +import { + showParametricLabelInput, hideParametricLabelInput, + parametricLabelInputActive, updateAnnotProp, validateSymbolParams, +} from '../bridge.js'; + +let editingAnnotation = null; + +function activeCanvas(annotation) { + const documentState = getActiveDocument(); + if (documentState?.viewMode === 'continuous') { + return document.querySelector( + `.annotation-canvas[data-page="${annotation.page || documentState.currentPage}"]`, + ); + } + const annotationPage = annotation.page ?? documentState?.currentPage; + if (annotationPage !== documentState?.currentPage) return null; + return annotationCanvas || document.getElementById('annotation-canvas'); +} + +function rotatedLabelCenter(annotation, label) { + const point = { + x: label.rect.x + label.rect.width / 2, + y: label.rect.y + label.rect.height / 2, + }; + const angle = (Number(annotation.rotation) || 0) * Math.PI / 180; + if (!angle) return point; + const cx = annotation.x + annotation.width / 2; + const cy = annotation.y + annotation.height / 2; + const dx = point.x - cx; + const dy = point.y - cy; + return { + x: cx + dx * Math.cos(angle) - dy * Math.sin(angle), + y: cy + dx * Math.sin(angle) + dy * Math.cos(angle), + }; +} + +function labelScreenPosition(annotation, label) { + const canvas = activeCanvas(annotation); + if (!canvas) return null; + const canvasRect = canvas.getBoundingClientRect(); + const documentState = getActiveDocument(); + const point = rotatedLabelCenter(annotation, label); + const useViewport = documentState?.viewMode !== 'continuous' + && viewportState?.active && documentState?.filePath; + const scale = useViewport ? viewportState.zoom : (documentState?.scale || 1.5); + const offsetX = useViewport ? viewportState.offsetX : 0; + const offsetY = useViewport ? viewportState.offsetY : 0; + return { + left: canvasRect.left + offsetX + point.x * scale, + top: canvasRect.top + offsetY + point.y * scale, + }; +} + +function stillAlive(annotation) { + const documentState = getActiveDocument(); + return Array.isArray(documentState?.annotations) + && documentState.annotations.includes(annotation); +} + +export function startParametricSymbolInput(annotation, x, y) { + if (!annotation || annotation.type !== 'parametricSymbol' || annotation.locked) return; + const label = findEditableLabel(annotation, x, y); + if (!label) return; + const template = getTemplate(annotation.symbolId); + const fieldKeys = new Set(label.fields); + const fieldDefinitions = (template?.params || []) + .filter((definition) => fieldKeys.has(definition.key)); + if (!fieldDefinitions.length) return; + + if (editingAnnotation || parametricLabelInputActive()) cancelParametricSymbolInput(); + const anchor = labelScreenPosition(annotation, label); + if (!anchor) return; + + editingAnnotation = annotation; + const values = Object.fromEntries( + fieldDefinitions.map((definition) => [ + definition.key, + annotation.params?.[definition.key] ?? definition.default ?? '', + ]), + ); + const locate = () => { + if (!editingAnnotation || !stillAlive(editingAnnotation)) return null; + return labelScreenPosition(editingAnnotation, label); + }; + showParametricLabelInput({ + anchor, + fields: fieldDefinitions, + values, + locate, + returnFocusTarget: activeCanvas(annotation), + commit: (inputValues) => { + const current = editingAnnotation; + if (!current || current.locked || locate() === null) { + editingAnnotation = null; + return; + } + editingAnnotation = null; + const nextParams = { + ...(current.params || {}), + ...inputValues, + }; + const validated = validateSymbolParams(annotation.symbolId, nextParams); + updateAnnotProp('params', validated); + }, + cancel: () => { + editingAnnotation = null; + }, + }); +} + +export function cancelParametricSymbolInput() { + if (!editingAnnotation && !parametricLabelInputActive()) return; + editingAnnotation = null; + hideParametricLabelInput(); +} + +export function isParametricSymbolInputOpen() { + return !!editingAnnotation && parametricLabelInputActive(); +} diff --git a/open-pdf-studio/js/tools/tool-dispatcher.js b/open-pdf-studio/js/tools/tool-dispatcher.js index 820510a9..28a4c641 100644 --- a/open-pdf-studio/js/tools/tool-dispatcher.js +++ b/open-pdf-studio/js/tools/tool-dispatcher.js @@ -337,6 +337,18 @@ export function handleDblClick(e) { import('./stavenreeks-editing.js') .then(m => m.startStavenreeksInput(clicked)) .catch(err => console.error('[dispatcher] stavenreeks inline input error', err)); + } else if (clicked.type === 'parametricSymbol') { + state.isDrawing = false; + if (dblDoc) { + dblDoc.selectedAnnotations = [clicked]; + dblDoc.selectedAnnotation = clicked; + } + showProperties(clicked); + import('./parametric-symbol-editing.js') + .then((module) => + module.startParametricSymbolInput(clicked, coords.x, coords.y)) + .catch((error) => + console.error('[dispatcher] parametric label input error', error)); } else if (clicked.type === 'comment') { state.isDrawing = false; if (dblDoc) { dblDoc.selectedAnnotations = [clicked]; dblDoc.selectedAnnotation = clicked; } diff --git a/open-pdf-studio/js/types/preferences.ts b/open-pdf-studio/js/types/preferences.ts index bf2c3732..a560c7f5 100644 --- a/open-pdf-studio/js/types/preferences.ts +++ b/open-pdf-studio/js/types/preferences.ts @@ -229,6 +229,7 @@ export interface Preferences { symbolPaletteFloatY: number; customSymbolGroups: Array<{ id: string; name: string; symbols: Array<{ id: string; name: string; svg: string }> }>; disabledSymbolGroups: string[]; + parametricSymbolDefaults: Record>; // Schedule scheduleTemplates: Array<{ name: string; groupBy: string; filterType: string; filterPage: number; created: number }>; diff --git a/open-pdf-studio/scripts/test-nl-ifc-parametric-components.mjs b/open-pdf-studio/scripts/test-nl-ifc-parametric-components.mjs index a5e08168..5c3a513c 100644 --- a/open-pdf-studio/scripts/test-nl-ifc-parametric-components.mjs +++ b/open-pdf-studio/scripts/test-nl-ifc-parametric-components.mjs @@ -78,7 +78,7 @@ const templates = [ console.log('\n== Identiteit en parameters'); ok(new Set(templates.map((t) => t.id)).size === 5, 'vijf unieke template-id\'s'); ok(paramKeys(wapeningsstaafTemplate).join(',') === - 'aantal,diameter,lengte,markerPositie,markerRichting', + 'aantal,diameter,lengte,markerAantal,markerPositie,markerRichting', 'wapeningsstaaf heeft alle instelbare waarden'); ok(paramKeys(netwapeningTemplate).join(',') === 'diameter,afstand,lengte,markerPositie,markerRichting', @@ -126,6 +126,18 @@ near(boven.markerX, 100, 1e-9, 'markerpositie 20%'); near(onder.markerX, 400, 1e-9, 'markerpositie 80%'); ok(boven.markerTipY < boven.lineY && onder.markerTipY > onder.lineY, 'marker kan boven en onder de lijn staan'); +for (const markerAantal of [1, 2, 3, 4]) { + for (const markerRichting of ['boven', 'onder']) { + const commands = wapeningsstaafTemplate.render({ + aantal: 3, diameter: 8, lengte: 1600, + markerAantal, markerPositie: 50, markerRichting, + }, { x: 0, y: 0, width: 320, height: 48 }); + const markers = commands.filter((command) => command.role === 'marker'); + ok(markers.length === markerAantal, `${markerAantal} vlaggen ${markerRichting}`); + ok(markers.every((command) => command.points.every((point) => point.x >= 0 && point.x <= 320)), + 'vlaggen blijven binnen de staaf'); + } +} function commandExtents(command) { if (command.kind === 'line') { return { minX: Math.min(command.x1, command.x2), maxX: Math.max(command.x1, command.x2), diff --git a/open-pdf-studio/scripts/test-parametric-label-editing.mjs b/open-pdf-studio/scripts/test-parametric-label-editing.mjs new file mode 100644 index 00000000..1c77f23a --- /dev/null +++ b/open-pdf-studio/scripts/test-parametric-label-editing.mjs @@ -0,0 +1,467 @@ +// Gerichte test voor bewerkbare labels van parametrische symbolen. +// Draaien: node scripts/test-parametric-label-editing.mjs + +import { + existsSync, readFileSync, writeFileSync, mkdtempSync, mkdirSync, +} from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { tmpdir } from 'node:os'; + +const here = dirname(fileURLToPath(import.meta.url)); +const appRoot = join(here, '..'); +const tmp = mkdtempSync(join(tmpdir(), 'opds-parametric-label-')); + +function stageMjs(relPath) { + const source = readFileSync(join(appRoot, relPath), 'utf8') + .replace(/(from\s*['"])(\.{1,2}\/[^'"]+)\.js(['"])/g, '$1$2.mjs$3') + .replace(/(import\(\s*['"])(\.{1,2}\/[^'"]+)\.js(['"]\s*\))/g, '$1$2.mjs$3'); + const target = join(tmp, relPath).replace(/\.js$/, '.mjs'); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, source); + return target; +} + +function writeStub(relPath, contents) { + const target = join(tmp, relPath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); + return target; +} + +stageMjs('js/annotations/stavenreeks.js'); +const lineModule = await import(pathToFileURL( + stageMjs('js/symbols/templates/wapening-lijn.js'), +).href); +const cageModule = await import(pathToFileURL( + stageMjs('js/symbols/templates/wapeningskorf.js'), +).href); + +const registryPath = join(tmp, 'js/symbols/registry.mjs'); +writeFileSync(registryPath, ` +import { wapeningsstaafTemplate, netwapeningTemplate } from './templates/wapening-lijn.mjs'; +import { wapeningskorfTemplate } from './templates/wapeningskorf.mjs'; +const templates = new Map([ + ['wapeningsstaaf', wapeningsstaafTemplate], + ['netwapening', netwapeningTemplate], + ['wapeningskorf', wapeningskorfTemplate], +]); +export function getTemplate(id) { return templates.get(id) || null; } +`); +const editingModule = await import(pathToFileURL( + stageMjs('js/symbols/editable-labels.js'), +).href); + +const { + wapeningsstaafTemplate, netwapeningTemplate, +} = lineModule; +const { wapeningskorfTemplate } = cageModule; +const { findEditableLabel } = editingModule; + +let checks = 0; +let failures = 0; +function ok(condition, message) { + checks++; + if (condition) return; + failures++; + console.error(` FOUT: ${message}`); +} + +const lineBox = { x: 100, y: 200, width: 320, height: 48 }; +const barParams = { + aantal: 3, diameter: 8, lengte: 1600, + markerAantal: 1, markerPositie: 25, markerRichting: 'boven', +}; +const netParams = { + diameter: 8, afstand: 150, lengte: 1600, + markerPositie: 25, markerRichting: 'boven', +}; +const cageParams = { + breedte: 400, hoogte: 400, dekking: 30, + bovenAantal: 4, bovenDiameter: 12, + zijAantal: 2, zijDiameter: 10, + onderAantal: 6, onderDiameter: 16, + beugelDiameter: 8, beugelAfstand: 150, + naam: 'Korf A', +}; +const cageBox = { x: 10, y: 20, width: 600, height: 500 }; + +console.log('\n== Labelcontract'); +const barLabels = wapeningsstaafTemplate.editableLabels(barParams, lineBox); +const netLabels = netwapeningTemplate.editableLabels(netParams, lineBox); +const cageLabels = wapeningskorfTemplate.editableLabels(cageParams, cageBox); +ok(barLabels.length === 1, 'staaf levert één labelgebied'); +ok(barLabels[0].fields.join(',') === 'aantal,diameter,lengte', + 'staaflabel koppelt drie velden'); +ok(netLabels.length === 1, 'net levert één labelgebied'); +ok(netLabels[0].fields.join(',') === 'diameter,afstand,lengte', + 'netlabel koppelt drie velden'); +ok(cageLabels.map((label) => label.id).join(',') === 'boven,zij,onder,beugel,naam', + 'korf levert vijf bewerkbare labels'); +ok(cageLabels.map((label) => label.fields.join('+')).join(',') === + 'bovenAantal+bovenDiameter,zijAantal+zijDiameter,onderAantal+onderDiameter,' + + 'beugelDiameter+beugelAfstand,naam', + 'korflabels koppelen de juiste veldgroepen'); +ok([...barLabels, ...netLabels, ...cageLabels].every(({ rect }) => + Number.isFinite(rect.x) && Number.isFinite(rect.y) + && rect.width > 0 && rect.height > 0), +'alle labelgebieden hebben geldige rechthoeken'); +ok(wapeningskorfTemplate.editableLabels( + { ...cageParams, toonLabels: false }, + cageBox, +).map((label) => label.id).join(',') === 'naam', +'verborgen korflabels leveren alleen het nog zichtbare onderschrift als hitgebied'); +ok(!wapeningskorfTemplate.editableLabels( + { ...cageParams, bovenAantal: 0 }, + cageBox, +).some((label) => label.id === 'boven'), +'een staafgroep zonder staven levert geen labelhitgebied'); +ok(!wapeningskorfTemplate.editableLabels( + { ...cageParams, naam: '' }, + cageBox, +).some((label) => label.id === 'naam'), +'een leeg onderschrift levert geen labelhitgebied'); + +console.log('\n== Geroteerde hit-testing'); +const annotation = { + type: 'parametricSymbol', + symbolId: 'wapeningsstaaf', + params: barParams, + rotation: 45, + ...lineBox, +}; +const rect = barLabels[0].rect; +const local = { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, +}; +const center = { + x: annotation.x + annotation.width / 2, + y: annotation.y + annotation.height / 2, +}; +const angle = annotation.rotation * Math.PI / 180; +const dx = local.x - center.x; +const dy = local.y - center.y; +const rotated = { + x: center.x + dx * Math.cos(angle) - dy * Math.sin(angle), + y: center.y + dx * Math.sin(angle) + dy * Math.cos(angle), +}; +ok(findEditableLabel(annotation, rotated.x, rotated.y)?.id === 'label', + 'inverse rotatie vindt het staaflabel'); +ok(findEditableLabel(annotation, annotation.x - 100, annotation.y - 100) === null, + 'punt buiten het geroteerde label levert null'); +ok(findEditableLabel({ ...annotation, type: 'line' }, rotated.x, rotated.y) === null, + 'niet-parametrische annotatie levert null'); + +console.log('\n== Paginawissel annuleert zonder commit'); +globalThis.__parametricTestCanvas = { + getBoundingClientRect() { + return { left: 20, top: 30, width: 800, height: 600 }; + }, +}; +globalThis.document = { + querySelector: () => null, + getElementById: () => globalThis.__parametricTestCanvas, +}; +writeStub('js/core/state.mjs', ` +export const documentState = { + id: 'document-1', + viewMode: 'single', + currentPage: 1, + scale: 1, + filePath: null, + pdfDoc: {}, + annotations: [], + selectedAnnotation: null, + selectedAnnotations: [], + undoStack: [], + redoStack: [], + savedUndoStackLength: 0, + modified: false, +}; +export const state = { + documents: [documentState], + activeDocumentIndex: 0, + defaultAuthor: 'Test', +}; +export function getActiveDocument() { return documentState; } +export function getPageRotation() { return 0; } +export function setPageRotation() {} +`); +writeStub('js/ui/dom-elements.mjs', + 'export const annotationCanvas = globalThis.__parametricTestCanvas;\n'); +writeStub('js/pdf/pdf-viewport.mjs', + 'export const viewport = { active: false, zoom: 1, offsetX: 0, offsetY: 0 };\n'); +stageMjs('js/annotations/factory.js'); +stageMjs('js/core/undo-manager.js'); +writeStub('js/ui/panels/left-panel.mjs', + 'export function invalidateThumbnails() {}\n'); +writeStub('js/ui/panels/properties-panel.mjs', ` +export function showProperties() {} +export function showMultiSelectionProperties() {} +export function hideProperties() {} +`); +writeStub('js/annotations/rendering.mjs', ` +export function redrawAnnotations() {} +export function redrawContinuous() {} +export function updateQuickAccessButtons() {} +`); +writeStub('js/solid/stores/leftPanelStore.mjs', + "export function activeTab() { return 'none'; }\n"); +writeStub('js/ui/panels/bookmarks.mjs', + 'export function updateBookmarksList() {}\n'); +writeStub('js/bridge.mjs', ` +import { recordPropertyChange } from './core/undo-manager.mjs'; +import { documentState } from './core/state.mjs'; +let active = false; +export let lastInput = null; +export let updateCount = 0; +export function showParametricLabelInput(options) { + active = true; + lastInput = options; +} +export function hideParametricLabelInput() { active = false; } +export function parametricLabelInputActive() { return active; } +export function updateAnnotProp(key, value) { + updateCount++; + const annotation = documentState.selectedAnnotation; + recordPropertyChange(annotation); + annotation[key] = value; +} +export function validateSymbolParams(_symbolId, params) { + return { + ...params, + aantal: Number(params.aantal), + }; +} +`); +const pageEditing = await import(pathToFileURL( + stageMjs('js/tools/parametric-symbol-editing.js'), +).href); +const pageState = await import(pathToFileURL(join(tmp, 'js/core/state.mjs')).href); +const pageBridge = await import(pathToFileURL(join(tmp, 'js/bridge.mjs')).href); +const pageAnnotation = { + id: 'staaf-op-pagina-1', + type: 'parametricSymbol', + symbolId: 'wapeningsstaaf', + page: 1, + params: { ...barParams }, + rotation: 0, + ...lineBox, +}; +pageState.documentState.annotations = [pageAnnotation]; +pageState.documentState.selectedAnnotation = pageAnnotation; +pageState.documentState.selectedAnnotations = [pageAnnotation]; +pageEditing.startParametricSymbolInput(pageAnnotation, local.x, local.y); +ok(!!pageBridge.lastInput, 'labelinvoer opent op de actuele pagina'); +pageState.documentState.currentPage = 2; +ok(pageBridge.lastInput?.locate() === null, + 'locator verdwijnt zodra enkelpaginaweergave naar een andere pagina wisselt'); +pageBridge.lastInput?.commit({ aantal: 9 }); +ok(pageBridge.updateCount === 0, + 'buitenklikcommit na paginawissel voert geen annotatie-update uit'); +ok(pageAnnotation.params.aantal === barParams.aantal, + 'paginawissel laat de bestaande labelparameters ongemoeid'); + +console.log('\n== Undo, annuleren en redo'); +const undoManager = await import(pathToFileURL( + join(tmp, 'js/core/undo-manager.mjs'), +).href); +pageState.documentState.currentPage = 1; +pageState.documentState.undoStack = []; +pageState.documentState.redoStack = []; +pageAnnotation.params = { ...barParams }; +pageEditing.startParametricSymbolInput(pageAnnotation, local.x, local.y); +pageBridge.lastInput?.commit({ aantal: '9' }); +await Promise.resolve(); +undoManager.flushPropertyChange(); +ok(pageState.documentState.undoStack.length === 1, + 'labelbevestiging maakt precies één undo-snapshot'); +ok(pageAnnotation.params.aantal === 9, + 'labelbevestiging schrijft de genormaliseerde parameter'); +await undoManager.undo(); +ok(pageAnnotation.params.aantal === barParams.aantal, + 'undo herstelt de parameter van vóór labelbevestiging'); +await undoManager.redo(); +ok(pageAnnotation.params.aantal === 9, + 'redo herstelt de bevestigde labelparameter'); + +pageState.documentState.undoStack = []; +pageState.documentState.redoStack = []; +pageAnnotation.params = { ...barParams }; +pageEditing.startParametricSymbolInput(pageAnnotation, local.x, local.y); +pageBridge.lastInput?.cancel(); +pageBridge.hideParametricLabelInput(); +await Promise.resolve(); +undoManager.flushPropertyChange(); +ok(pageState.documentState.undoStack.length === 0, + 'Escape-annulering maakt geen undo-snapshot'); +ok(pageAnnotation.params.aantal === barParams.aantal, + 'Escape-annulering laat de parameter ongemoeid'); + +console.log('\n== Buitenklik- en toolwisseleventvolgorde'); +const { createOutsideCommitController } = await import(pathToFileURL( + stageMjs('js/solid/components/parametric-label-outside-events.js'), +).href); +let editorActive = true; +let commitCount = 0; +const editorRoot = { + contains(target) { + return target?.area === 'editor'; + }, +}; +const outsideController = createOutsideCommitController({ + isActive: () => editorActive, + commit: () => { commitCount++; }, + isCanvasTarget: (target) => target?.area === 'canvas', +}); +const toolbarTarget = { area: 'toolbar' }; +outsideController.pointerDown({ target: toolbarTarget }, editorRoot); +editorActive = false; // setTool annuleert tijdens de toolbar-clickhandler. +outsideController.click({ target: toolbarTarget }, editorRoot); +ok(commitCount === 0, + 'pointerdown gevolgd door toolwissel annuleert zonder voorafgaande commit'); + +editorActive = true; +const panelTarget = { area: 'panel' }; +outsideController.pointerDown({ target: panelTarget }, editorRoot); +outsideController.click({ target: panelTarget }, editorRoot); +ok(commitCount === 1, 'gewone niet-canvas-buitenklik commit na de clickhandler'); + +const canvasTarget = { area: 'canvas' }; +outsideController.pointerDown({ target: canvasTarget }, editorRoot); +ok(commitCount === 2, 'canvas-buitenklik commit vóór de canvashandler'); +outsideController.click({ target: canvasTarget }, editorRoot); +ok(commitCount === 2, 'canvas-buitenklik commit niet dubbel op click'); + +console.log('\n== Focusteruggave'); +const focusHelperPath = join( + appRoot, + 'js/solid/components/parametric-label-focus.js', +); +ok(existsSync(focusHelperPath), 'geteste focushulp bestaat'); +if (existsSync(focusHelperPath)) { + const focusModule = await import(pathToFileURL( + stageMjs('js/solid/components/parametric-label-focus.js'), + ).href); + let previousFocusCount = 0; + const focusDocument = { + activeElement: null, + body: {}, + querySelector: () => fallbackCanvas, + }; + const previousFocus = { + isConnected: true, + ownerDocument: focusDocument, + focus(options) { + if (options?.preventScroll) previousFocusCount++; + focusDocument.activeElement = previousFocus; + }, + }; + const fallbackCanvas = { + isConnected: true, + ownerDocument: focusDocument, + focus() { + focusDocument.activeElement = fallbackCanvas; + }, + }; + const activePageAttributes = new Map(); + const activePageCanvas = { + isConnected: true, + ownerDocument: focusDocument, + hasAttribute: (name) => activePageAttributes.has(name), + setAttribute: (name, value) => activePageAttributes.set(name, value), + focus() { + focusDocument.activeElement = activePageCanvas; + }, + }; + focusDocument.activeElement = previousFocus; + const rememberedFocus = focusModule.captureParametricLabelReturnFocus(focusDocument); + ok(focusModule.restoreParametricLabelFocus(rememberedFocus), + 'focusherstel meldt succes wanneer het vorige element focus ontvangt'); + ok(previousFocusCount === 1, + 'sluiten na Enter of Escape geeft focus terug aan het vorige element'); + focusDocument.activeElement = focusDocument.body; + const pageFocus = focusModule.captureParametricLabelReturnFocus( + focusDocument, + activePageCanvas, + ); + ok(pageFocus === activePageCanvas, + 'zonder bruikbare vorige focus wordt het annotatiecanvas van de bewerkte pagina onthouden'); + ok(activePageAttributes.get('tabindex') === '-1', + 'het annotatiecanvas wordt programmatisch focusbaar zonder een tabstop toe te voegen'); + const refusesFocus = { + isConnected: true, + ownerDocument: focusDocument, + focus() {}, + }; + focusDocument.activeElement = focusDocument.body; + ok(!focusModule.restoreParametricLabelFocus(refusesFocus), + 'focusherstel meldt geen succes wanneer de browser focus weigert'); +} + +console.log('\n== Editor- en lifecyclecontract'); +const source = (relPath) => readFileSync(join(appRoot, relPath), 'utf8'); +const storeSource = source('js/solid/stores/parametricLabelInputStore.js'); +const editorSource = source('js/solid/components/ParametricLabelInlineEditor.jsx'); +const bridgeSource = source('js/tools/parametric-symbol-editing.js'); +const dispatcherSource = source('js/tools/tool-dispatcher.js'); +const managerSource = source('js/tools/manager.js'); +const dialogHostSource = source('js/solid/components/DialogHost.jsx'); +const cssSource = source('styles/dialogs.css'); +const solidBridgeSource = source('js/bridge.ts'); + +for (const signal of [ + 'active', 'anchor', 'fields', 'values', 'onCommit', 'onCancel', 'locator', + 'returnFocusTarget', +]) { + ok(storeSource.includes(`const [${signal},`), `store bewaart ${signal}`); +} +ok(editorSource.includes(''), 'editor rendert generieke velddefinities'); +ok(editorSource.includes("e.key === 'Enter'") && editorSource.includes('commit()'), + 'Enter bevestigt de editor'); +ok(editorSource.includes("e.key === 'Escape'") && editorSource.includes('cancel()'), + 'Escape annuleert de editor'); +ok(editorSource.includes('e.stopPropagation()'), 'toetsen lekken niet naar canvassneltoetsen'); +ok(editorSource.includes('captureParametricLabelReturnFocus') + && editorSource.includes('restoreParametricLabelFocus'), +'editor herstelt focus via de geteste focushulp'); +ok(editorSource.includes("document.addEventListener('pointerdown', onOutsidePointerDown, true)") + && editorSource.includes("window.addEventListener('click', onOutsideClick)"), +'editor onderscheidt directe canvascommit van toolwisselgevoelige click'); +ok(editorSource.includes('requestAnimationFrame(tick)') && editorSource.includes('if (!pos)'), + 'editor volgt de locator en sluit als die verdwijnt'); +ok(bridgeSource.includes('findEditableLabel(annotation, x, y)'), + 'vanilla brug zoekt het aangeklikte label'); +ok(bridgeSource.includes('returnFocusTarget: activeCanvas(annotation)'), + 'vanilla brug bewaart het annotatiecanvas van de bewerkte pagina'); +ok(bridgeSource.includes('validateSymbolParams(annotation.symbolId, nextParams)'), + 'commit normaliseert alle parameters'); +ok((bridgeSource.match(/updateAnnotProp\('params',/g) || []).length === 1, + 'brug bevat één volledige params-update'); +ok(dispatcherSource.includes("clicked.type === 'parametricSymbol'") + && dispatcherSource.includes('startParametricSymbolInput(clicked, coords.x, coords.y)'), +'dubbelklik opent parametrische labelinvoer'); +ok(managerSource.includes('cancelParametricSymbolInput()'), + 'toolwissel annuleert parametrische labelinvoer'); +ok(managerSource.includes( + "import { cancelParametricSymbolInput } from './parametric-symbol-editing.js';", +), 'toolwissel heeft een synchrone cancelimport'); +ok(dialogHostSource.includes(''), + 'generieke editor is in DialogHost gemonteerd'); +ok(cssSource.includes('.parametric-label-inline-editor'), + 'generieke editor heeft thema-opmaak'); +ok(solidBridgeSource.includes('showParametricLabelInput') + && solidBridgeSource.includes('hideParametricLabelInput'), +'Solid-store is via de vanilla bridge ontsloten'); +ok(solidBridgeSource.includes('validateSymbolParams'), + 'parametervalidatie is via de vanilla bridge ontsloten'); +ok(!bridgeSource.includes( + "from '../solid/stores/parametricSymbolStore.js'", +), 'vanilla editing importeert geen Solid-store rechtstreeks'); + +if (failures) { + console.error(`\n${failures} van ${checks} controles mislukt.`); + process.exit(1); +} +console.log(`\nOK: ${checks} controles geslaagd.`); diff --git a/open-pdf-studio/scripts/test-parametric-symbol-defaults.mjs b/open-pdf-studio/scripts/test-parametric-symbol-defaults.mjs new file mode 100644 index 00000000..5e171fd8 --- /dev/null +++ b/open-pdf-studio/scripts/test-parametric-symbol-defaults.mjs @@ -0,0 +1,257 @@ +import { ok, strictEqual, deepStrictEqual } from 'node:assert'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const root = new URL('..', import.meta.url); +const sourcePath = (path) => new URL(path, root); +const preferencesSource = await readFile(sourcePath('js/types/preferences.ts'), 'utf8'); +const constantsSource = await readFile(sourcePath('js/core/constants.ts'), 'utf8'); +const storeSource = await readFile(sourcePath('js/solid/stores/parametricSymbolStore.js'), 'utf8'); +const managerSource = await readFile(sourcePath('js/tools/manager.js'), 'utf8'); +const creatorSource = await readFile(sourcePath('js/tools/annotation-creators.js'), 'utf8'); + +ok(preferencesSource.includes('parametricSymbolDefaults'), + 'voorkeurenschema bevat parametrische symboolwaarden'); +ok(constantsSource.includes('parametricSymbolDefaults: {}'), + 'standaardvoorkeuren starten met lege parametrische symboolwaarden'); +ok(storeSource.includes('export function resolveSymbolParams'), + 'store levert gevalideerde effectieve parameters'); +ok(managerSource.includes("tool === 'parametricSymbol'"), + 'manager toont voor parametrische plaatsing eigenschappen'); +ok(creatorSource.includes('pendingParams()'), + 'creator gebruikt actieve waarden in plaats van kale sjabloondefaults'); + +const tempDir = await mkdtemp(join(tmpdir(), 'parametric-symbol-defaults-')); +const stubDir = join(tempDir, 'stubs'); + +try { + await mkdir(stubDir); + const statePath = join(stubDir, 'state.mjs'); + const preferencesPath = join(stubDir, 'preferences.mjs'); + const registryPath = join(stubDir, 'registry.mjs'); + const solidPath = join(stubDir, 'solid.mjs'); + const stagedStorePath = join(tempDir, 'parametricSymbolStore.mjs'); + + await writeFile(solidPath, ` +export function createSignal(initial) { + let value = initial; + return [() => value, (next) => { value = typeof next === 'function' ? next(value) : next; return value; }]; +} +`); + await writeFile(statePath, 'export const state = { preferences: { parametricSymbolDefaults: {} } };\n'); + await writeFile(preferencesPath, 'export function savePreferences() {}\n'); + await writeFile(registryPath, ` +const templates = { + door: { params: [ + { key: 'width', type: 'number', default: 900, min: 100, max: 4000 }, + { key: 'showWall', type: 'boolean', default: false }, + ] }, + window: { params: [ + { key: 'width', type: 'number', default: 1200, min: 200, max: 6000 }, + ] }, +}; +export const getTemplate = (id) => templates[id] || null; +export const listTemplates = () => Object.values(templates); +export const defaultParams = (template) => Object.fromEntries((template?.params || []).map((param) => [param.key, param.default])); +`); + + const stagedStore = storeSource + .replace("from 'solid-js'", `from '${pathToFileURL(solidPath).href}'`) + .replace("from '../../core/state.js'", `from '${pathToFileURL(statePath).href}'`) + .replace("from '../../core/preferences.js'", `from '${pathToFileURL(preferencesPath).href}'`) + .replace("from '../../symbols/registry.js'", `from '${pathToFileURL(registryPath).href}'`) + .replace("import('../../core/preferences.js')", `import('${pathToFileURL(preferencesPath).href}')`); + await writeFile(stagedStorePath, stagedStore); + + const store = await import(`${pathToFileURL(stagedStorePath).href}?${Date.now()}`); + const state = await import(pathToFileURL(statePath).href); + store.setPendingSymbolId('door'); + store.setPendingParams({ width: 1200, showWall: true }); + store.setPendingSymbolId('window'); + store.setPendingParams({ width: 1800 }); + + deepStrictEqual(store.resolveSymbolParams('door'), { width: 1200, showWall: true }, + 'elke sjabloon bewaart eigen gevalideerde waarden'); + strictEqual(store.resolveSymbolParams('window').width, 1800, + 'een tweede symbool gebruikt zijn eigen opgeslagen waarde'); + state.state.preferences.parametricSymbolDefaults.door.width = 'ongeldig'; + strictEqual(store.resolveSymbolParams('door').width, 900, + 'ongeldige numerieke waarden vallen terug op de sjabloonstandaard'); + + const managerDir = join(tempDir, 'manager', 'js', 'tools'); + const managerStoresDir = join(tempDir, 'manager', 'js', 'solid', 'stores'); + const managerCoreDir = join(tempDir, 'manager', 'js', 'core'); + await mkdir(managerDir, { recursive: true }); + await mkdir(managerStoresDir, { recursive: true }); + await mkdir(managerCoreDir, { recursive: true }); + + const managerDepsPath = join(tempDir, 'manager', 'deps.mjs'); + await writeFile(managerDepsPath, ` +export const state = globalThis.__managerState; +export function getActiveDocument() { return globalThis.__managerDocument; } +export function hideProperties() { globalThis.__managerPanel.current = null; } +export function redrawAnnotations() {} +export function redrawContinuous() {} +export function updateStatusTool() {} +export function isPdfAReadOnly() { return false; } +export function getAnnotationType() { return null; } +export function getTool() { return null; } +export function buildToolContext() { return {}; } +export function resolvePointerCoords() { return { x: 0, y: 0 }; } +export function findAnnotationAt() { return null; } +export function findHandleAt() { return null; } +export function cancelParametricSymbolInput() {} +`); + await writeFile(join(managerStoresDir, 'parametricSymbolStore.mjs'), ` +export function pendingSymbolId() { return 'wapeningsstaaf'; } +export function pendingParams() { return { markerAantal: 4, markerRichting: 'onder' }; } +`); + await writeFile(join(managerStoresDir, 'propertiesStore.mjs'), ` +async function waitForGate() { + const gate = globalThis.__managerDefaultsGate; + if (gate) await gate; +} +export async function showToolDefaults(tool, overrides, shouldShow) { + await waitForGate(); + if (typeof shouldShow === 'function' && !shouldShow()) return false; + globalThis.__managerPanel.current = { + id: '__tool-defaults__', + type: tool, + ...overrides, + }; + return true; +} +export function hideToolDefaults() { + if (globalThis.__managerPanel.current?.id !== '__tool-defaults__') return false; + globalThis.__managerPanel.current = null; + return true; +} +`); + await writeFile(join(managerCoreDir, 'preferences.mjs'), ` +export function getStyleMapping() { return null; } +`); + await writeFile(join(managerDir, 'stavenreeks-editing.mjs'), + 'export function cancelStavenreeksInput() {}\n'); + await writeFile(join(managerDir, 'text-edit-tool.mjs'), + 'export function deactivateEditTextTool() {}\nexport function activateEditTextTool() {}\n'); + + const stagedManagerSource = managerSource + .replace(/from\s+['"][^'"]+['"]/g, `from '${pathToFileURL(managerDepsPath).href}'`) + .replace(/(import\(\s*['"])(\.{1,2}\/[^'"]+)\.js(['"]\s*\))/g, '$1$2.mjs$3'); + const stagedManagerPath = join(managerDir, 'manager.mjs'); + await writeFile(stagedManagerPath, stagedManagerSource); + + globalThis.__managerState = { + currentTool: 'select', + preferences: {}, + toolOverrides: null, + }; + globalThis.__managerDocument = { + viewMode: 'single', + selectedAnnotation: null, + selectedAnnotations: [], + }; + globalThis.__managerPanel = { current: null }; + globalThis.__managerDefaultsGate = null; + globalThis.document = { + querySelectorAll: () => [], + querySelector: () => null, + getElementById: () => null, + addEventListener() {}, + removeEventListener() {}, + }; + globalThis.window = {}; + + const manager = await import(`${pathToFileURL(stagedManagerPath).href}?${Date.now()}`); + const settleManager = async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }; + + manager.setTool('parametricSymbol'); + await settleManager(); + strictEqual(globalThis.__managerPanel.current?.id, '__tool-defaults__', + 'parametrische keuze toont een synthetisch eigenschappenobject'); + manager.setTool('select'); + await settleManager(); + strictEqual(globalThis.__managerPanel.current, null, + 'terugschakelen naar select ruimt het actuele synthetische object op'); + + const realSelection = { id: 'annotatie-1', type: 'line' }; + globalThis.__managerPanel.current = realSelection; + manager.setTool('select'); + await settleManager(); + strictEqual(globalThis.__managerPanel.current, realSelection, + 'select bewaart een werkelijk geselecteerd eigenschappenobject'); + + let releaseDefaults; + globalThis.__managerPanel.current = null; + globalThis.__managerDefaultsGate = new Promise((resolve) => { + releaseDefaults = resolve; + }); + manager.setTool('parametricSymbol'); + manager.setTool('select'); + releaseDefaults(); + await settleManager(); + strictEqual(globalThis.__managerPanel.current, null, + 'een vertraagde defaults-aanvraag mag na een toolwissel niet terugkeren'); + + const creatorDepsPath = join(tempDir, 'annotation-creator-deps.mjs'); + await writeFile(creatorDepsPath, ` +export const state = { + preferences: { enableAngleSnap: false }, + toolOverrides: null, + currentPath: [], +}; +export function getActiveDocument() { + return globalThis.__creatorDocument; +} +export function getColorPickerValue() { return '#000000'; } +export function getLineWidthValue() { return 1; } +export function createAnnotation(props) { return { id: 'created', ...props }; } +export function snapAngle(value) { return value; } +export function calculateDistance() { return { value: 0, unit: 'mm', pixels: 0 }; } +export function calculateArea() { return { value: 0, unit: 'mm2' }; } +export function calculatePerimeter() { return { value: 0, unit: 'mm' }; } +export function formatMeasurement() { return '0 mm'; } +export function snapDistanceTo10(value) { return value; } +export function getAnnotationType() { return null; } +export function applyDynamicScaling() {} +export function getTemplate() { return null; } +export function pxPerMmAt() { return 1; } +export function syncTwoPointGeometry() {} +export function syncTwoPointLengthParam() {} +export function pendingParams() { return {}; } +export function pendingSymbolId() { return 'onbekend-sjabloon'; } +export function activeCountCategory() { return null; } +export function nextCountNumber() { return 1; } +export function ifcCategoryForParametric() { return ''; } +export function ifcCategoryForAnnotationType() { return ''; } +export const STAVENREEKS_DEFAULTS = {}; +`); + const stagedCreatorPath = join(tempDir, 'annotation-creators.mjs'); + await writeFile(stagedCreatorPath, creatorSource.replace( + /from\s+['"][^'"]+['"]/g, + `from '${pathToFileURL(creatorDepsPath).href}'`, + )); + globalThis.__creatorDocument = { currentPage: 1, annotations: [] }; + const creators = await import(`${pathToFileURL(stagedCreatorPath).href}?${Date.now()}`); + strictEqual( + creators.buildAnnotationProps('parametricSymbol', 10, 20, 10, 20, null), + null, + 'een onbekend parametrisch sjabloon levert geen plaatsbare eigenschappen', + ); + deepStrictEqual(globalThis.__creatorDocument.annotations, [], + 'een onbekend parametrisch sjabloon muteert het document niet'); +} finally { + delete globalThis.__managerState; + delete globalThis.__managerDocument; + delete globalThis.__managerPanel; + delete globalThis.__managerDefaultsGate; + delete globalThis.__creatorDocument; + await rm(tempDir, { recursive: true, force: true }); +} + +console.log('PASS test-parametric-symbol-defaults'); diff --git a/open-pdf-studio/scripts/test-wapeningsstaaf-two-point.mjs b/open-pdf-studio/scripts/test-wapeningsstaaf-two-point.mjs index 201c2258..7fd4d066 100644 --- a/open-pdf-studio/scripts/test-wapeningsstaaf-two-point.mjs +++ b/open-pdf-studio/scripts/test-wapeningsstaaf-two-point.mjs @@ -1,9 +1,12 @@ // Gerichte regressietest voor de tweepuntsplaatsing van Wapeningsstaaf. // Draaien: node scripts/test-wapeningsstaaf-two-point.mjs -import { existsSync, readFileSync } from 'node:fs'; +import { + existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync, +} from 'node:fs'; import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { fileURLToPath, pathToFileURL } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const appRoot = join(here, '..'); @@ -116,6 +119,200 @@ ok(converterSource.includes('extraColors.opsTwoPoint'), 'PDF-converter herstelt ok(xfdfSource.includes('opstwopoint='), 'XFDF bewaart beide punten'); ok(xfdfSource.includes("getAttribute('opstwopoint')"), 'XFDF-loader herstelt beide punten'); +console.log('\n== Werkelijke parameter-rondreis'); +const markerFixture = { + aantal: 3, + diameter: 8, + lengte: 1600, + markerAantal: 4, + markerPositie: 50, + markerRichting: 'onder', +}; +const persistenceTmp = mkdtempSync(join(tmpdir(), 'opds-parametric-persistence-')); + +function stageWithDependencies(name, relPath, dependencies, append = '') { + const depsPath = join(persistenceTmp, `${name}-deps.mjs`); + const modulePath = join(persistenceTmp, `${name}.mjs`); + writeFileSync(depsPath, dependencies); + const staged = readFileSync(join(appRoot, relPath), 'utf8') + .replace(/from\s+['"][^'"]+['"]/g, `from '${pathToFileURL(depsPath).href}'`); + writeFileSync(modulePath, `${staged}${append}`); + return modulePath; +} + +try { + const converterPath = stageWithDependencies( + 'annotation-converter', + 'js/pdf/loader/annotation-converter.js', + ` +export const state = {}; +export const imageCache = new Map(); +export function createAnnotation(props) { return { id: 'pdf-restored', ...props }; } +export function generateImageId() { return 'image-id'; } +export function colorArrayToHex(_value, fallback) { return fallback; } +export function mapPdfFontName(value) { return value; } +export function mapBorderStyle(value) { return value; } +export function calculateDistance() { return { value: 0, unit: 'mm', pixels: 0 }; } +export function calculateArea() { return { value: 0, unit: 'mm2' }; } +export function calculatePerimeter() { return { value: 0, unit: 'mm' }; } +export function formatMeasurement() { return '0 mm'; } +export function findImageForAnnotation() { return null; } +export function ifcCategoryForAnnotationType() { return ''; } +export function ifcCategoryForParametric() { return 'IfcReinforcingBar'; } +export const STAVENREEKS_DEFAULTS = {}; +export function syncTwoPointGeometry(annotation, startX, startY, endX, endY, height) { + Object.assign(annotation, { startX, startY, endX, endY, height }); +} +`, + ); + const converter = await import(pathToFileURL(converterPath).href); + const pdfMetadata = { + opsSubtype: 'parametricSymbol', + opsSymbolId: 'wapeningsstaaf', + opsParams: JSON.stringify(markerFixture), + opsIfcCategory: 'IfcReinforcingBar', + }; + const pdfRestored = await converter.convertPdfAnnotation( + { + subtype: 'Square', + rect: [0, 0, 320, 48], + color: [0, 0, 0], + annotationFlags: 4, + borderStyle: { width: 1 }, + }, + 1, + { + convertToViewportPoint: (x, y) => [x, y], + convertToViewportRectangle: (rect) => rect, + }, + new Map(), + new Map([['0,0,320,48', pdfMetadata]]), + ); + ok(pdfRestored.params.markerAantal === 4, + 'PDF-metadataherstel bewaart markerAantal 4'); + ok(pdfRestored.params.markerRichting === 'onder', + 'PDF-metadataherstel bewaart markerRichting onder'); + + globalThis.__xfdfDocument = { + filePath: 'wapening.pdf', + annotations: [{ + id: 'staaf-1', + type: 'parametricSymbol', + page: 1, + x: 0, + y: 0, + width: 320, + height: 48, + symbolId: 'wapeningsstaaf', + params: { ...markerFixture }, + strokeColor: '#000000', + lineWidth: 1, + rotation: 0, + opacity: 1, + printable: true, + }], + }; + const xfdfPath = stageWithDependencies( + 'xfdf', + 'js/annotations/xfdf.js', + ` +export const state = {}; +export function getActiveDocument() { return globalThis.__xfdfDocument; } +export function createAnnotation(props) { return { id: 'xfdf-restored', ...props }; } +export function cloneAnnotation(value) { return JSON.parse(JSON.stringify(value)); } +export function recordBulkAdd() {} +export function redrawAnnotations() {} +export function redrawContinuous() {} +export function updateStatusMessage() {} +export const isTauri = false; +export async function readBinaryFile() { return null; } +export async function writeBinaryFile() {} +export async function saveFileDialog() { return null; } +export async function openFileDialog() { return null; } +export default { t(key) { return key; } } +export function showMessage() {} +export function ifcCategoryForParametric() { return 'IfcReinforcingBar'; } +export function syncTwoPointGeometry(annotation, startX, startY, endX, endY, height) { + Object.assign(annotation, { startX, startY, endX, endY, height }); +} +`, + '\nexport { xfdfElementToAnnotation };\n', + ); + const xfdf = await import(pathToFileURL(xfdfPath).href); + const xml = xfdf.exportToXFDF(); + const squareTag = xml.match(/]*opstype="parametricSymbol"[^>]*>/)?.[0] || ''; + const attrs = {}; + for (const match of squareTag.matchAll(/([\w-]+)="([^"]*)"/g)) { + attrs[match[1]] = match[2] + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); + } + const xfdfRestored = xfdf.xfdfElementToAnnotation({ + localName: 'square', + getAttribute: (name) => attrs[name] ?? null, + querySelector: (name) => (name === 'contents' ? { textContent: '' } : null), + querySelectorAll: () => [], + }); + ok(xfdfRestored.params.markerAantal === 4, + 'XFDF-export en -import bewaren markerAantal 4'); + ok(xfdfRestored.params.markerRichting === 'onder', + 'XFDF-export en -import bewaren markerRichting onder'); + + globalThis.__clipboardState = { + defaultAuthor: 'Test', + clipboardAnnotation: null, + clipboardAnnotations: null, + _pasteSeq: 0, + }; + globalThis.__clipboardDocument = { + pdfDoc: {}, + currentPage: 1, + viewMode: 'single', + annotations: [], + selectedAnnotation: null, + selectedAnnotations: [], + }; + const clipboardPath = stageWithDependencies( + 'clipboard', + 'js/annotations/clipboard.js', + ` +export const state = globalThis.__clipboardState; +export function getActiveDocument() { return globalThis.__clipboardDocument; } +export const imageCache = new Map(); +export function cloneAnnotation(value) { return JSON.parse(JSON.stringify(value)); } +export function cloneAnnotationsInPlace(values) { + return values.map((value) => JSON.parse(JSON.stringify(value))); +} +export function generateImageId() { return 'image-id'; } +export function updateStatusMessage() {} +export function showProperties() {} +export function showMultiSelectionProperties() {} +export function redrawAnnotations() {} +export function redrawContinuous() {} +export const annotationCanvas = null; +export const pdfContainer = null; +export function recordAdd() {} +export function recordBulkAdd() {} +`, + ); + const clipboard = await import(pathToFileURL(clipboardPath).href); + clipboard.copyAnnotation(globalThis.__xfdfDocument.annotations[0]); + clipboard.pasteAnnotation(); + const pasted = globalThis.__clipboardDocument.annotations[0]; + ok(pasted.params.markerAantal === 4, + 'kopiëren en plakken bewaren markerAantal 4'); + ok(pasted.params.markerRichting === 'onder', + 'kopiëren en plakken bewaren markerRichting onder'); +} finally { + delete globalThis.__xfdfDocument; + delete globalThis.__clipboardState; + delete globalThis.__clipboardDocument; + rmSync(persistenceTmp, { recursive: true, force: true }); +} + if (failures) { console.error(`\n${failures} van ${checks} controles mislukt.`); process.exit(1); diff --git a/open-pdf-studio/src-tauri/Cargo.lock b/open-pdf-studio/src-tauri/Cargo.lock index ffd689bd..b536748a 100644 --- a/open-pdf-studio/src-tauri/Cargo.lock +++ b/open-pdf-studio/src-tauri/Cargo.lock @@ -3237,7 +3237,7 @@ dependencies = [ [[package]] name = "open-pdf-studio" -version = "1.81.0" +version = "1.82.0" dependencies = [ "axum", "base64 0.22.1", diff --git a/open-pdf-studio/styles/dialogs.css b/open-pdf-studio/styles/dialogs.css index 5808c6b6..8758b953 100644 --- a/open-pdf-studio/styles/dialogs.css +++ b/open-pdf-studio/styles/dialogs.css @@ -4382,7 +4382,8 @@ select.ste-field { padding: 2px 4px; cursor: pointer; } Compact Windows-uiterlijk: rechte hoeken, dunne rand, geen animatie. Kleuren via dezelfde thema-variabelen als het eigenschappen-paneel, zodat het venstertje in elk thema leesbaar blijft. */ -.stavenreeks-inline-editor { +.stavenreeks-inline-editor, +.parametric-label-inline-editor { display: flex; gap: 6px; align-items: flex-end; @@ -4395,19 +4396,22 @@ select.ste-field { padding: 2px 4px; cursor: pointer; } font-family: inherit; color: var(--theme-text, #1f1f1f); } -.stavenreeks-inline-editor .sr-inline-field { +.stavenreeks-inline-editor .sr-inline-field, +.parametric-label-inline-editor .parametric-label-inline-field { display: flex; flex-direction: column; gap: 2px; } -.stavenreeks-inline-editor .sr-inline-field > span { +.stavenreeks-inline-editor .sr-inline-field > span, +.parametric-label-inline-editor .parametric-label-inline-field > span { font-size: 10px; font-weight: 500; white-space: nowrap; color: var(--theme-text-secondary, #555); } .stavenreeks-inline-editor input, -.stavenreeks-inline-editor select { +.stavenreeks-inline-editor select, +.parametric-label-inline-editor input { font-size: 12px; font-family: inherit; height: 22px; @@ -4421,3 +4425,9 @@ select.ste-field { padding: 2px 4px; cursor: pointer; } } .stavenreeks-inline-editor input { width: 58px; } .stavenreeks-inline-editor select { width: 76px; cursor: pointer; } +.parametric-label-inline-editor { + transform: translate(-50%, 8px); +} +.parametric-label-inline-editor input { + width: 82px; +}