github-actions[bot]
Integrate compact bound placeholder display
daa082c
Raw
History Blame Contribute Delete
18.2 kB
(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);