From 55a9e56140520c521312f9408682a3a9e724d32c Mon Sep 17 00:00:00 2001 From: Daniel Apodaca Date: Wed, 1 Jul 2026 14:34:13 -0600 Subject: [PATCH 1/6] inverse mask implementation --- enferno/admin/templates/admin/jsapi.jinja2 | 18 ++ enferno/static/js/components/MediaRedactor.js | 204 +++++++++++++++--- 2 files changed, 189 insertions(+), 33 deletions(-) diff --git a/enferno/admin/templates/admin/jsapi.jinja2 b/enferno/admin/templates/admin/jsapi.jinja2 index d95b84d17..bb112186e 100644 --- a/enferno/admin/templates/admin/jsapi.jinja2 +++ b/enferno/admin/templates/admin/jsapi.jinja2 @@ -629,6 +629,24 @@ previewNotAvailable_ : "{{ _('Preview not available') }}", downloadFile_ : "{{ _('Download file') }}", onlyRedactionsCanBeDeleted_: "{{ _('Only redactions can be deleted') }}", redact_: "{{ _('Redact') }}", +redactionTool_: "{{ _('Redaction Tool') }}", +document_: "{{ _('document') }}", +copyName_: "{{ _('Copy name') }}", +saveChanges_: "{{ _('Save changes') }}", +saveCopy_: "{{ _('Save copy') }}", +saveAsNewCopy_: "{{ _('Save as new copy') }}", +drawAtLeastOneRevealWindowOnTheDocumentToSave_: "{{ _('Draw at least one reveal window on the document to save') }}", +drawAtLeastOneBlackBoxOnTheDocumentToSave_: "{{ _('Draw at least one black box on the document to save') }}", +revealMode_: "{{ _('Reveal mode') }}", +clickDragToDrawAVisibleWindow_: "{{ _('Click & drag to draw a visible window') }}", +clickDragToDrawABlackBox_: "{{ _('Click & drag to draw a black box') }}", +dragBoxToReposition_: "{{ _('Drag box to reposition') }}", +clickBoxThenDeleteToRemove_: "{{ _('Click box then Delete to remove') }}", +holdSpaceAndDragToPan_: "{{ _('Hold Space + drag to pan') }}", +fit_: "{{ _('Fit') }}", +unsupportedFileType_: "{{ _('Unsupported file type') }}", +failedToLoadDocument_: "{{ _('Failed to load document') }}", +failedToSaveRedaction_: "{{ _('Failed to save redaction') }}", youreAboutToDeleteARedactedCopy_: "{{ _('You\'re about to delete a redacted copy') }}", deletingTheRedactedCopyWillNotDeleteTheOriginalMedia_: "{{ _('Deleting this redacted copy will not delete the original media.') }}", deleteRedaction_: "{{ _('Delete Redaction') }}", diff --git a/enferno/static/js/components/MediaRedactor.js b/enferno/static/js/components/MediaRedactor.js index 24848bf0e..78787cf2a 100644 --- a/enferno/static/js/components/MediaRedactor.js +++ b/enferno/static/js/components/MediaRedactor.js @@ -13,6 +13,8 @@ const MediaRedactor = Vue.defineComponent({ loading: false, saving: false, error: null, + translations: window.translations, + maskMode: 'redact', label: '', pages: [], boxes: {}, @@ -60,6 +62,15 @@ const MediaRedactor = Vue.defineComponent({ canSubmit() { return Object.values(this.boxes).some(pageBoxes => pageBoxes.length); }, + modeMeta() { + return this.maskMode === 'reveal' + ? { + boxHint: this.translations.clickDragToDrawAVisibleWindow_, + } + : { + boxHint: this.translations.clickDragToDrawABlackBox_, + }; + }, orientation() { return this.media?.orientation || 0; }, @@ -136,6 +147,7 @@ const MediaRedactor = Vue.defineComponent({ this.loading = false; this.saving = false; this.error = null; + this.maskMode = 'redact'; this.label = ''; this.pages = []; this.boxes = {}; @@ -162,7 +174,7 @@ const MediaRedactor = Vue.defineComponent({ this.boxes = { 0: [] }; return; } - this.error = 'Unsupported file type'; + this.error = this.translations.unsupportedFileType_; }, async loadPdf() { const loadId = this._loadId; @@ -193,7 +205,7 @@ const MediaRedactor = Vue.defineComponent({ } catch (e) { if (loadId !== this._loadId) return; console.error('PDF redactor load failed:', e); - this.error = 'Failed to load document'; + this.error = this.translations.failedToLoadDocument_; this.loading = false; } }, @@ -227,17 +239,19 @@ const MediaRedactor = Vue.defineComponent({ }, normalizedPoint(index, event) { const rect = this.pageRect(index); - const x = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1); - const y = Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1); + const source = event.changedTouches?.[0] || event.touches?.[0] || event; + const x = Math.min(Math.max((source.clientX - rect.left) / rect.width, 0), 1); + const y = Math.min(Math.max((source.clientY - rect.top) / rect.height, 0), 1); return { x, y }; }, startBox(index, event) { - if (event.button !== 0 || this.spaceDown) return; + if ((event.button !== undefined && event.button !== 0) || this.spaceDown) return; const point = this.normalizedPoint(index, event); this.draft = { page: index, startX: point.x, startY: point.y, x: point.x, y: point.y, w: 0, h: 0 }; }, startDrag(pageIndex, boxIndex, event) { - if (event.button !== 0) return; + if (event.pointerType === 'touch') return; + if (event.button !== undefined && event.button !== 0) return; event.stopPropagation(); this.activeBox = { page: pageIndex, box: boxIndex }; const point = this.normalizedPoint(pageIndex, event); @@ -246,7 +260,8 @@ const MediaRedactor = Vue.defineComponent({ }, // corner: 'tl' | 'tr' | 'bl' | 'br' — the corner being dragged; opposite corner is the anchor startResize(pageIndex, boxIndex, corner, event) { - if (event.button !== 0) return; + if (event.pointerType === 'touch') return; + if (event.button !== undefined && event.button !== 0) return; event.stopPropagation(); this.activeBox = { page: pageIndex, box: boxIndex }; const box = this.boxes[pageIndex][boxIndex]; @@ -327,7 +342,7 @@ const MediaRedactor = Vue.defineComponent({ } }, onPanStart(event) { - if (!this.spaceDown || event.button !== 0) return; + if (!this.spaceDown || (event.button !== undefined && event.button !== 0)) return; event.preventDefault(); event.stopPropagation(); const pane = this._scrollPane; @@ -350,6 +365,88 @@ const MediaRedactor = Vue.defineComponent({ if (this.draft?.page === pageIndex) boxes.push(this.draft); return boxes; }, + inverseOverlayPath(pageIndex) { + const boxes = this.visibleBoxes(pageIndex); + const path = ['M 0 0 H 100 V 100 H 0 Z']; + for (const box of boxes) { + const left = +(box.x * 100).toFixed(4); + const top = +(box.y * 100).toFixed(4); + const right = +((box.x + box.w) * 100).toFixed(4); + const bottom = +((box.y + box.h) * 100).toFixed(4); + path.push(`M ${left} ${top} H ${right} V ${bottom} H ${left} Z`); + } + return path.join(' '); + }, + normalizeRect(rect) { + const x0 = Math.max(0, Math.min(rect.x, 1)); + const y0 = Math.max(0, Math.min(rect.y, 1)); + const x1 = Math.max(x0, Math.min(rect.x + rect.w, 1)); + const y1 = Math.max(y0, Math.min(rect.y + rect.h, 1)); + return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; + }, + mergeRects(rects) { + const epsilon = 0.000001; + let current = rects.map(rect => this.normalizeRect(rect)).filter(rect => rect.w > epsilon && rect.h > epsilon); + let changed = true; + while (changed) { + current.sort((a, b) => (a.y - b.y) || (a.x - b.x) || (a.h - b.h) || (a.w - b.w)); + changed = false; + const next = []; + const used = new Array(current.length).fill(false); + for (let i = 0; i < current.length; i++) { + if (used[i]) continue; + let rect = current[i]; + for (let j = i + 1; j < current.length; j++) { + if (used[j]) continue; + const other = current[j]; + const sameRow = Math.abs(rect.y - other.y) < epsilon && Math.abs(rect.h - other.h) < epsilon; + const touchesHorizontally = sameRow && Math.abs(rect.x + rect.w - other.x) < epsilon; + const sameColumn = Math.abs(rect.x - other.x) < epsilon && Math.abs(rect.w - other.w) < epsilon; + const touchesVertically = sameColumn && Math.abs(rect.y + rect.h - other.y) < epsilon; + if (touchesHorizontally) { + rect = { x: rect.x, y: rect.y, w: rect.w + other.w, h: rect.h }; + used[j] = true; + changed = true; + } else if (touchesVertically) { + rect = { x: rect.x, y: rect.y, w: rect.w, h: rect.h + other.h }; + used[j] = true; + changed = true; + } + } + used[i] = true; + next.push(rect); + } + current = next; + } + return current; + }, + inverseRects(rects) { + if (!rects.length) return []; + const normalizedRects = this.mergeRects(rects); + const xEdges = Array.from(new Set([0, 1, ...normalizedRects.flatMap(rect => [rect.x, rect.x + rect.w])])).sort((a, b) => a - b); + const yEdges = Array.from(new Set([0, 1, ...normalizedRects.flatMap(rect => [rect.y, rect.y + rect.h])])).sort((a, b) => a - b); + const maskedRects = []; + + for (let yi = 0; yi < yEdges.length - 1; yi++) { + for (let xi = 0; xi < xEdges.length - 1; xi++) { + const x0 = xEdges[xi]; + const x1 = xEdges[xi + 1]; + const y0 = yEdges[yi]; + const y1 = yEdges[yi + 1]; + if (x1 <= x0 || y1 <= y0) continue; + const midX = (x0 + x1) / 2; + const midY = (y0 + y1) / 2; + const insideReveal = normalizedRects.some(rect => + midX >= rect.x && midX <= rect.x + rect.w && midY >= rect.y && midY <= rect.y + rect.h + ); + if (!insideReveal) { + maskedRects.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 }); + } + } + } + + return this.mergeRects(maskedRects); + }, zoomIn() { this.zoom = Math.min(+(this.zoom + 0.25).toFixed(2), REDACTOR_MAX_ZOOM); }, @@ -414,13 +511,26 @@ const MediaRedactor = Vue.defineComponent({ this.saving = true; const pages = Object.entries(this.boxes) .filter(([, rects]) => rects.length) - .map(([page, rects]) => ({ page: Number(page), rects })); + .map(([page, rects]) => { + const sourceRects = this.mergeRects(rects); + return { + page: Number(page), + mode: this.maskMode, + rects: this.maskMode === 'reveal' ? this.inverseRects(sourceRects) : sourceRects, + revealRects: this.maskMode === 'reveal' ? sourceRects : undefined, + }; + }); try { - const response = await api.post(`/admin/api/media/${this.media.id}/redact`, { pages, title: this.label.trim(), overwrite }); + const response = await api.post(`/admin/api/media/${this.media.id}/redact`, { + mode: this.maskMode, + pages, + title: this.label.trim(), + overwrite, + }); this.$emit('redacted', response.data.data); this.show = false; } catch (e) { - this.error = e.response?.data?.message || 'Failed to save redaction'; + this.error = e.response?.data?.message || this.translations.failedToSaveRedaction_; } finally { this.saving = false; } @@ -430,19 +540,20 @@ const MediaRedactor = Vue.defineComponent({ template: /*html*/` - + - - Redaction Tool - — {{ media?.title || media?.filename || 'document' }} + +
{{ translations.redactionTool_ }}
+
{{ media?.title || media?.filename || translations.document_ }}
Save changes + >{{ translations.saveChanges_ }} {{ isRedactedCopy ? 'Save as new copy' : 'Save redacted copy' }} + >{{ $vuetify.display.mobile ? translations.save_ : (isRedactedCopy ? translations.saveAsNewCopy_ : translations.saveCopy_) }} - Draw at least one black box on the document to save + {{ maskMode === 'reveal' ? translations.drawAtLeastOneRevealWindowOnTheDocumentToSave_ : translations.drawAtLeastOneBlackBoxOnTheDocumentToSave_ }}
- - Click & drag to draw a black box - Drag box to reposition - Click box then Delete to remove - Hold Space + drag to pan + +
+ {{ translations.revealMode_ }} + {{ modeMeta.boxHint }} + {{ translations.dragBoxToReposition_ }} + {{ translations.clickBoxThenDeleteToRemove_ }} + {{ translations.holdSpaceAndDragToPan_ }} {{ Math.round(zoom * 100) }}% - Fit + {{ translations.fit_ }} +
{{ error }} @@ -506,7 +626,8 @@ const MediaRedactor = Vue.defineComponent({
+ + + +