diff --git a/enferno/admin/templates/admin/jsapi.jinja2 b/enferno/admin/templates/admin/jsapi.jinja2 index d95b84d17..7b1b00612 100644 --- a/enferno/admin/templates/admin/jsapi.jinja2 +++ b/enferno/admin/templates/admin/jsapi.jinja2 @@ -629,6 +629,25 @@ 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_: "{{ _('Create 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 select what stays visible') }}", +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') }}", +redactionEditingIsAvailableOnDesktopOnly_: "{{ _('Redaction editing is available on desktop only') }}", 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..4a9e020fb 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, + redactionMode: 'redact', label: '', pages: [], boxes: {}, @@ -60,6 +62,24 @@ const MediaRedactor = Vue.defineComponent({ canSubmit() { return Object.values(this.boxes).some(pageBoxes => pageBoxes.length); }, + isMobileView() { + return !!this.$vuetify?.display?.mobile; + }, + isDarkTheme() { + return !!this.$vuetify?.theme?.current?.dark; + }, + scrollPaneClasses() { + return this.isDarkTheme ? 'bg-grey-darken-4' : 'bg-grey-lighten-3'; + }, + modeHint() { + return this.redactionMode === 'reveal' + ? { + boxHint: this.translations.clickDragToDrawAVisibleWindow_, + } + : { + boxHint: this.translations.clickDragToDrawABlackBox_, + }; + }, orientation() { return this.media?.orientation || 0; }, @@ -99,12 +119,16 @@ const MediaRedactor = Vue.defineComponent({ } window.addEventListener('keydown', this._keydownHandler); window.addEventListener('keyup', this._keyupHandler); + window.addEventListener('resize', this._resizeHandler); + this._resizeObserver?.observe?.(this._scrollPane || document.body); } else { this._scrollPane?.removeEventListener('touchmove', this._touchMoveHandler); this._scrollPane?.removeEventListener('wheel', this._wheelHandler); this._scrollPane = null; window.removeEventListener('keydown', this._keydownHandler); window.removeEventListener('keyup', this._keyupHandler); + window.removeEventListener('resize', this._resizeHandler); + this._resizeObserver?.disconnect?.(); this.reset(); } }, @@ -120,6 +144,10 @@ const MediaRedactor = Vue.defineComponent({ this._wheelHandler = (e) => this.onWheel(e); this._keydownHandler = (e) => this.onKeydown(e); this._keyupHandler = (e) => this.onKeyup(e); + this._resizeHandler = () => this.onViewportResize(); + this._resizeObserver = typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => this.onViewportResize()) + : null; }, beforeUnmount() { @@ -127,6 +155,8 @@ const MediaRedactor = Vue.defineComponent({ this._scrollPane?.removeEventListener('wheel', this._wheelHandler); window.removeEventListener('keydown', this._keydownHandler); window.removeEventListener('keyup', this._keyupHandler); + window.removeEventListener('resize', this._resizeHandler); + this._resizeObserver?.disconnect?.(); this._pdf?.destroy?.(); }, @@ -136,6 +166,7 @@ const MediaRedactor = Vue.defineComponent({ this.loading = false; this.saving = false; this.error = null; + this.redactionMode = 'redact'; this.label = ''; this.pages = []; this.boxes = {}; @@ -153,6 +184,9 @@ const MediaRedactor = Vue.defineComponent({ this._pdf?.destroy?.(); this._pdf = null; }, + onViewportResize() { + this.baseWidth = 0; + }, async load() { this.reset(); if (!this.src) return; @@ -162,7 +196,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 +227,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,18 +261,24 @@ 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 (this.isMobileView) return; + if ((event.button !== undefined && event.button !== 0) || this.spaceDown) return; + event.currentTarget?.setPointerCapture?.(event.pointerId); 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 (this.isMobileView) return; + if (event.pointerType === 'touch') return; + if (event.button !== undefined && event.button !== 0) return; event.stopPropagation(); + event.currentTarget?.setPointerCapture?.(event.pointerId); this.activeBox = { page: pageIndex, box: boxIndex }; const point = this.normalizedPoint(pageIndex, event); const box = this.boxes[pageIndex][boxIndex]; @@ -246,8 +286,11 @@ 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 (this.isMobileView) return; + if (event.pointerType === 'touch') return; + if (event.button !== undefined && event.button !== 0) return; event.stopPropagation(); + event.currentTarget?.setPointerCapture?.(event.pointerId); this.activeBox = { page: pageIndex, box: boxIndex }; const box = this.boxes[pageIndex][boxIndex]; const anchorX = corner.includes('l') ? box.x + box.w : box.x; @@ -255,6 +298,7 @@ const MediaRedactor = Vue.defineComponent({ this.resize = { page: pageIndex, boxIndex, anchorX, anchorY }; }, moveBox(index, event) { + if (this.isMobileView) return; if (this.resize && this.resize.page === index) { const point = this.normalizedPoint(index, event); const { anchorX, anchorY, boxIndex } = this.resize; @@ -289,7 +333,11 @@ const MediaRedactor = Vue.defineComponent({ const y1 = Math.max(this.draft.startY, point.y); this.draft = { ...this.draft, x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; }, - finishBox() { + finishBox(event) { + if (this.isMobileView) return; + if (event?.currentTarget?.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } if (this.resize) { this.resize = null; return; } if (this.drag) { this.drag = null; return; } if (!this.draft) return; @@ -301,6 +349,7 @@ const MediaRedactor = Vue.defineComponent({ this.draft = null; }, deleteBox(pageIndex, boxIndex) { + if (this.isMobileView) return; const pageBoxes = [...(this.boxes[pageIndex] || [])]; pageBoxes.splice(boxIndex, 1); this.boxes = { ...this.boxes, [pageIndex]: pageBoxes }; @@ -308,6 +357,7 @@ const MediaRedactor = Vue.defineComponent({ if (this.activeBox?.page === pageIndex && this.activeBox?.box === boxIndex) this.activeBox = null; }, onKeydown(event) { + if (this.isMobileView) return; if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return; if (event.code === 'Space' && !this.spaceDown) { event.preventDefault(); @@ -327,7 +377,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 +400,91 @@ const MediaRedactor = Vue.defineComponent({ if (this.draft?.page === pageIndex) boxes.push(this.draft); return boxes; }, + revealOverlayPath(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; + }, + revealRectsToRedactionRects(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); + }, + toggleRevealMode() { + this.redactionMode = this.redactionMode === 'reveal' ? 'redact' : 'reveal'; + }, zoomIn() { this.zoom = Math.min(+(this.zoom + 0.25).toFixed(2), REDACTOR_MAX_ZOOM); }, @@ -414,13 +549,28 @@ 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); + // Reveal mode is a client-side UX layer: users draw visible windows, + // then we convert them into standard blackout rects before saving. + return { + page: Number(page), + mode: this.redactionMode, + rects: this.redactionMode === 'reveal' ? this.revealRectsToRedactionRects(sourceRects) : sourceRects, + revealRects: this.redactionMode === '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.redactionMode, + 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.$root.showSnack(e.response?.data?.message || this.translations.failedToSaveRedaction_); } finally { this.saving = false; } @@ -430,26 +580,27 @@ const MediaRedactor = Vue.defineComponent({ template: /*html*/` - + - - Redaction Tool - — {{ media?.title || media?.filename || 'document' }} + +
{{ translations.redactionTool_ }}
+
{{ media?.title || media?.filename || translations.document_ }}
- Draw at least one black box on the document to save + {{ redactionMode === '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_ }} + {{ modeHint.boxHint }} + {{ translations.dragBoxToReposition_ }} + {{ translations.clickBoxThenDeleteToRemove_ }} + {{ translations.holdSpaceAndDragToPan_ }} {{ Math.round(zoom * 100) }}% - Fit + {{ translations.fit_ }} +
{{ error }} +
+ {{ translations.redactionEditingIsAvailableOnDesktopOnly_ }} +
+ + + +