(function (global) { "use strict"; const Bridge = global.SolidPrivacyStreamlitBridge; const Core = global.SolidPrivacySelectionCore; if (!Bridge || !Core) { throw new Error("SolidPrivacy component support files are missing"); } const sourcePane = document.getElementById("sourcePane"); const processedPane = document.getElementById("processedPane"); const processedLegend = document.getElementById("processedLegend"); const maskSelectionButton = document.getElementById("maskSelectionButton"); const contextMenu = document.getElementById("contextMenu"); const statusRegion = document.getElementById("statusRegion"); const componentRoot = document.getElementById("componentRoot"); const componentFooter = document.getElementById("componentFooter"); let currentArgs = {}; let processedText = ""; let highlightSpans = []; let renderedProcessedSegments = []; let selectionProtectedSpans = []; let currentSelection = null; let lastMenuPosition = { x: 16, y: 16 }; let lastRenderedInspectionToken = ""; let isSyncing = false; function clearElement(element) { element.replaceChildren(); } function appendSafeText(element, text) { element.appendChild(document.createTextNode(Core.asText(text))); } function renderPlainText(element, text) { clearElement(element); appendSafeText(element, text); } function renderProcessedText(text, spans) { clearElement(processedPane); renderedProcessedSegments = Core.buildDisplayTextSegments(text, spans); selectionProtectedSpans = Core.protectedSpansFromDisplaySegments(renderedProcessedSegments); renderedProcessedSegments.forEach(function (segment, index) { const element = document.createElement(segment.marked ? "mark" : "span"); element.className = segment.marked ? "sp-highlight sp-processed-segment" : "sp-processed-segment"; if (segment.compacted) { element.classList.add("sp-compact-placeholder"); element.title = `Volledige gebonden placeholder: ${segment.full_placeholder}`; element.setAttribute( "aria-label", `Gebonden placeholder, compact weergegeven als ${segment.display_text}`, ); } else if (segment.marked) { element.setAttribute("aria-label", "gemarkeerde vervanging"); } element.dataset.segmentIndex = String(index); element.dataset.startUtf16 = String(segment.start_utf16); element.dataset.endUtf16 = String(segment.end_utf16); appendSafeText(element, segment.display_text); processedPane.appendChild(element); }); } function getScrollRatio(element) { return Core.scrollRatio(element.scrollTop, element.scrollHeight, element.clientHeight); } function setScrollRatio(element, ratio) { element.scrollTop = Core.scrollTopForRatio(ratio, element.scrollHeight, element.clientHeight); } function syncScroll(fromPane, toPane) { if (isSyncing) { return; } isSyncing = true; global.requestAnimationFrame(function () { setScrollRatio(toPane, getScrollRatio(fromPane)); isSyncing = false; }); } function nodeWithin(rootElement, node) { if (!rootElement || !node) { return false; } const candidate = node.nodeType === Node.TEXT_NODE ? node.parentNode : node; return node === rootElement || rootElement.contains(candidate); } function domUtf16Offset(rootElement, container, offset) { if (!nodeWithin(rootElement, container)) { throw new Error("selection endpoint is outside processed text"); } if (container === rootElement) { if (!Number.isInteger(offset) || offset < 0 || offset > rootElement.childNodes.length) { throw new Error("selection endpoint is outside processed text"); } if (offset === 0) { return 0; } if (offset >= renderedProcessedSegments.length) { return processedText.length; } return renderedProcessedSegments[offset - 1].end_utf16; } const candidate = container.nodeType === Node.TEXT_NODE ? container.parentElement : container; const segmentElement = candidate && candidate.closest ? candidate.closest("[data-segment-index]") : null; if (!segmentElement || !rootElement.contains(segmentElement)) { throw new Error("selection endpoint has no source segment"); } const segmentIndex = Number(segmentElement.dataset.segmentIndex); const range = document.createRange(); range.selectNodeContents(segmentElement); range.setEnd(container, offset); return Core.utf16OffsetFromDisplaySegments( renderedProcessedSegments, segmentIndex, range.toString().length, ); } function readProcessedSelection() { const selection = global.getSelection(); if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) { return null; } const range = selection.getRangeAt(0); if (!nodeWithin(processedPane, range.startContainer) || !nodeWithin(processedPane, range.endContainer)) { return null; } let start; let end; try { start = domUtf16Offset(processedPane, range.startContainer, range.startOffset); end = domUtf16Offset(processedPane, range.endContainer, range.endOffset); } catch (_error) { return null; } return Core.selectionFromOffsets(processedText, selectionProtectedSpans, start, end); } function updateSelectionState() { currentSelection = readProcessedSelection(); maskSelectionButton.disabled = !currentSelection || currentSelection.intersects_marked_content; if (!currentSelection) { statusRegion.textContent = ""; } else if (currentSelection.intersects_marked_content) { statusRegion.textContent = "De selectie overlapt met een bestaande maskering."; } else { statusRegion.textContent = `“${currentSelection.text}” geselecteerd`; } } function closeMenu(options) { contextMenu.hidden = true; clearElement(contextMenu); if (options && options.restoreFocus) { processedPane.focus(); } } function menuItems() { return Array.from(contextMenu.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])')); } function focusMenuItem(index) { const items = menuItems(); if (!items.length) { return; } const bounded = ((index % items.length) + items.length) % items.length; items[bounded].focus(); } function positionMenu(x, y) { contextMenu.hidden = false; contextMenu.style.left = "0px"; contextMenu.style.top = "0px"; const rect = contextMenu.getBoundingClientRect(); const position = Core.clampMenuPosition( x, y, rect.width, rect.height, global.innerWidth, global.innerHeight, 8, ); contextMenu.style.left = `${position.x}px`; contextMenu.style.top = `${position.y}px`; lastMenuPosition = position; } function addSummary(text) { const summary = document.createElement("div"); summary.className = "sp-menu-summary"; summary.textContent = text; contextMenu.appendChild(summary); } function addMenuButton(label, callback, options) { const button = document.createElement("button"); button.type = "button"; button.className = "sp-menu-item"; button.setAttribute("role", "menuitem"); button.textContent = label; if (options && options.disabled) { button.setAttribute("aria-disabled", "true"); button.disabled = true; } button.addEventListener("click", function () { if (!button.disabled) { callback(); } }); contextMenu.appendChild(button); return button; } function addWarning(text) { const warning = document.createElement("div"); warning.className = "sp-menu-warning"; warning.textContent = text; contextMenu.appendChild(warning); } function inspectionToken(inspectionResult) { const result = inspectionResult || {}; return String(result.inspection_id || result.event_id || ""); } function inspectionMatchesSelection(inspectionResult) { const resultText = String((inspectionResult && inspectionResult.selection_text) || ""); return !currentSelection || !resultText || resultText === currentSelection.text; } function isInspectableResult(inspectionResult) { const status = String((inspectionResult && inspectionResult.status) || ""); return Boolean(inspectionToken(inspectionResult)) && ["ready", "confirmation_required", "blocked"].includes(status) && inspectionMatchesSelection(inspectionResult); } function emitInspectEvent() { if (!currentSelection || currentSelection.intersects_marked_content) { statusRegion.textContent = "Selecteer een ongemaskeerde waarde in de verwerkte tekst."; closeMenu({ restoreFocus: true }); return; } const event = Core.buildInspectEvent( currentArgs, currentSelection, { source_scroll_ratio: getScrollRatio(sourcePane), processed_scroll_ratio: getScrollRatio(processedPane), }, Core.makeEventId("inspect", global.crypto), ); statusRegion.textContent = "Selectie wordt veilig gecontroleerd…"; closeMenu(); Bridge.setComponentValue(event); } function emitCommitIntent(typeKey, inspectionResult) { const event = Core.buildCommitEvent( inspectionResult, typeKey, Core.makeEventId("commit", global.crypto), ); const contract = currentArgs.component_contract || {}; statusRegion.textContent = contract.non_mutating_spike ? "Maskeringskeuze is als niet-muterend intent-event verstuurd." : "Maskering wordt veilig toegevoegd…"; closeMenu(); Bridge.setComponentValue(event); } function renderInspectMenu(x, y) { clearElement(contextMenu); if (!currentSelection) { addSummary("Selecteer eerst een waarde in Verwerkte tekst."); addMenuButton("Sluiten", function () { closeMenu({ restoreFocus: true }); }); } else if (currentSelection.intersects_marked_content) { addSummary("Deze selectie overlapt met een bestaande maskering."); addMenuButton("Sluiten", function () { closeMenu({ restoreFocus: true }); }); } else { addSummary(`“${currentSelection.text}” geselecteerd`); addMenuButton("Selectie veilig inspecteren", emitInspectEvent); } positionMenu(x, y); focusMenuItem(0); } function renderConfirmationMenu(typeKey, inspectionResult, x, y) { clearElement(contextMenu); const label = Core.QUICK_TYPE_LABELS[typeKey]; const count = Number(inspectionResult.occurrence_count || 0); addSummary(`“${inspectionResult.selection_text || "selectie"}” — ${count} exacte voorkomens`); addWarning(`Alle ${count} exacte voorkomens worden gemaskeerd als ${label}.`); addMenuButton(`Bevestig alle ${count} voorkomens`, function () { emitCommitIntent(typeKey, inspectionResult); }); addMenuButton("Terug", function () { renderInspectionResultMenu(inspectionResult, x, y); }); positionMenu(x, y); focusMenuItem(0); } function renderInspectionResultMenu(inspectionResult, x, y) { clearElement(contextMenu); const status = String(inspectionResult.status || "blocked"); const count = Number(inspectionResult.occurrence_count || 0); const selectionText = String( inspectionResult.selection_text || (currentSelection && currentSelection.text) || "selectie", ); addSummary(`“${selectionText}” — ${inspectionResult.message || `${count} exacte voorkomens`}`); if (status === "blocked") { addWarning(inspectionResult.message || "Deze selectie kon niet veilig worden toegevoegd."); addMenuButton("Sluiten", function () { closeMenu({ restoreFocus: true }); }); } else { const allowed = Array.isArray(inspectionResult.allowed_types) ? inspectionResult.allowed_types : []; allowed.forEach(function (typeKey) { if (!Object.prototype.hasOwnProperty.call(Core.QUICK_TYPE_LABELS, typeKey)) { return; } const label = Core.QUICK_TYPE_LABELS[typeKey]; const buttonLabel = count === 1 ? `Masker als ${label}` : `Masker alle ${count} exacte voorkomens als ${label}`; addMenuButton(buttonLabel, function () { if (status === "confirmation_required") { renderConfirmationMenu(typeKey, inspectionResult, x, y); } else { emitCommitIntent(typeKey, inspectionResult); } }); }); if (!allowed.length) { addWarning("De server heeft geen toegestane maskeringstypen teruggegeven."); } } positionMenu(x, y); focusMenuItem(0); } function openMenuForCurrentState(x, y) { const inspectionResult = currentArgs.inspection_result || {}; if (isInspectableResult(inspectionResult)) { renderInspectionResultMenu(inspectionResult, x, y); } else { renderInspectMenu(x, y); } } function render(args) { const sourceScroll = getScrollRatio(sourcePane); const processedScroll = getScrollRatio(processedPane); currentArgs = args || {}; const contract = currentArgs.component_contract || {}; const nonMutatingSpike = Boolean(contract.non_mutating_spike); if (componentRoot) { componentRoot.setAttribute( "aria-label", nonMutatingSpike ? "Niet-muterende selectiecomponent" : "Tekstselectie voor handmatige maskering", ); } if (componentFooter) { componentFooter.textContent = nonMutatingSpike ? "De panelen scrollen samen. Deze componentproef wijzigt geen vervangtabel of document." : "De panelen scrollen samen. Maskeringen worden pas na servercontrole toegevoegd."; } processedText = Core.asText(currentArgs.processed_text); highlightSpans = Core.normalizeUtf16Spans(processedText, currentArgs.highlight_spans); renderPlainText(sourcePane, currentArgs.source_text); renderProcessedText(processedText, highlightSpans); const compactedCount = renderedProcessedSegments.filter(function (segment) { return segment.compacted; }).length; if (highlightSpans.length && compactedCount) { processedLegend.textContent = "Geel = vervangen; documentcode compact weergegeven"; } else if (highlightSpans.length) { processedLegend.textContent = "Geel = vervangen of gemaskeerde waarde"; } else if (compactedCount) { processedLegend.textContent = "Documentcode compact weergegeven"; } else { processedLegend.textContent = "Verwerkte tekst"; } const restoreSource = currentArgs.restore_source_scroll_ratio; const restoreProcessed = currentArgs.restore_processed_scroll_ratio; global.requestAnimationFrame(function () { setScrollRatio( sourcePane, Number.isFinite(Number(restoreSource)) ? Number(restoreSource) : sourceScroll, ); setScrollRatio( processedPane, Number.isFinite(Number(restoreProcessed)) ? Number(restoreProcessed) : processedScroll, ); }); updateSelectionState(); const inspectionResult = currentArgs.inspection_result || {}; if (inspectionResult.message) { statusRegion.textContent = String(inspectionResult.message); } const token = inspectionToken(inspectionResult); if (token && token !== lastRenderedInspectionToken && isInspectableResult(inspectionResult)) { lastRenderedInspectionToken = token; global.requestAnimationFrame(function () { renderInspectionResultMenu( inspectionResult, lastMenuPosition.x, lastMenuPosition.y, ); }); } Bridge.setFrameHeight(500); } sourcePane.addEventListener("scroll", function () { syncScroll(sourcePane, processedPane); }); processedPane.addEventListener("scroll", function () { syncScroll(processedPane, sourcePane); }); document.addEventListener("selectionchange", function () { updateSelectionState(); }); processedPane.addEventListener("contextmenu", function (event) { updateSelectionState(); if (!currentSelection || currentSelection.intersects_marked_content) { return; } event.preventDefault(); openMenuForCurrentState(event.clientX, event.clientY); }); processedPane.addEventListener("keydown", function (event) { if ((event.shiftKey && event.key === "F10") || event.key === "ContextMenu") { updateSelectionState(); if (currentSelection && !currentSelection.intersects_marked_content) { event.preventDefault(); const rect = processedPane.getBoundingClientRect(); openMenuForCurrentState(rect.left + Math.min(40, rect.width / 2), rect.top + 40); } } }); maskSelectionButton.addEventListener("click", function () { updateSelectionState(); const rect = maskSelectionButton.getBoundingClientRect(); openMenuForCurrentState(rect.left, rect.bottom + 6); }); contextMenu.addEventListener("keydown", function (event) { const items = menuItems(); const currentIndex = items.indexOf(document.activeElement); if (event.key === "ArrowDown") { event.preventDefault(); focusMenuItem(currentIndex + 1); } else if (event.key === "ArrowUp") { event.preventDefault(); focusMenuItem(currentIndex - 1); } else if (event.key === "Home") { event.preventDefault(); focusMenuItem(0); } else if (event.key === "End") { event.preventDefault(); focusMenuItem(items.length - 1); } else if (event.key === "Escape") { event.preventDefault(); closeMenu({ restoreFocus: true }); } }); document.addEventListener("mousedown", function (event) { if (!contextMenu.hidden && !contextMenu.contains(event.target)) { closeMenu(); } }); global.addEventListener("blur", function () { closeMenu(); }); Bridge.onRender(function (detail) { render(detail.args || {}); }); Bridge.setComponentReady(); Bridge.setFrameHeight(500); })(window);