add documents editor

This commit is contained in:
Pete Matsyburka
2026-06-10 15:12:16 +03:00
parent ac66809a05
commit 13874b8830
19 changed files with 4130 additions and 56 deletions
+37 -2
View File
@@ -320,6 +320,7 @@
@replace="onDocumentReplace"
@up="moveDocument(item, -1)"
@reorder="reorderFields"
@edit="editModalDocumentUuid = item.attachment_uuid"
@down="moveDocument(item, 1)"
@change="save"
/>
@@ -685,6 +686,17 @@
@close="isRevisionsModalOpen = false"
@apply="onRevisionApply"
/>
<DocumentsEditorModal
v-if="editModalDocumentUuid"
:template="template"
:authenticity-token="authenticityToken"
:accept-file-types="acceptFileTypes"
:base-url="baseUrl"
:page-preview-format="pagePreviewFormat"
:scroll-to-attachment-uuid="editModalDocumentUuid"
@saved="onDocumentsModified"
@close="editModalDocumentUuid = null"
/>
</div>
</div>
</template>
@@ -704,6 +716,7 @@ import DocumentControls from './controls'
import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
import RevisionsModal from './revisions_modal'
import DocumentsEditorModal from './documents_editor_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory, IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
@@ -747,7 +760,8 @@ export default {
IconHistory,
IconDeviceFloppy,
IconX,
RevisionsModal
RevisionsModal,
DocumentsEditorModal
},
provide () {
return {
@@ -1103,6 +1117,7 @@ export default {
isDragFile: false,
isMathLoaded: false,
isRevisionsModalOpen: false,
editModalDocumentUuid: null,
revisions: [],
beforeRevisionSnapshot: null,
zoomLevel: 1
@@ -1678,7 +1693,7 @@ export default {
ref.x = e.clientX - ref.offsetX
ref.y = e.clientY - ref.offsetY
} else if (e.dataTransfer?.types?.includes('Files')) {
} else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid) {
this.isDragFile = true
}
},
@@ -2121,6 +2136,10 @@ export default {
}
},
onKeyDown (event) {
if (this.editModalDocumentUuid) {
return
}
if (event.key === 'Tab' && document.activeElement === document.body) {
event.stopImmediatePropagation()
event.preventDefault()
@@ -3139,6 +3158,22 @@ export default {
onDocumentsReplaceAndTemplateClone (template) {
window.Turbo.visit(`/templates/${template.id}/edit`)
},
onDocumentsModified (data) {
this.template.schema = data.schema
this.template.fields = data.fields
this.template.submitters = data.submitters
this.template.documents = data.documents
this.selectedAreasRef.value = []
if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) {
this.selectedSubmitter = this.template.submitters[0]
}
this.editModalDocumentUuid = null
this.save()
},
moveDocument (item, direction) {
const currentIndex = this.template.schema.indexOf(item)
@@ -0,0 +1,328 @@
<template>
<div class="flex flex-1 min-h-0">
<div
class="flex-1 min-h-0 flex items-center justify-center px-6 py-4"
style="container-type: size"
>
<div
ref="pageEl"
class="relative select-none"
:style="pageStyle"
>
<img
:src="imageUrl"
:width="metadata.width"
:height="metadata.height"
class="absolute border rounded pointer-events-none"
style="left: 50%; top: 50%; width: 100%; height: auto"
:style="imageStyle"
>
<svg
class="absolute inset-0 w-full h-full pointer-events-none"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<path
:d="dimPath"
fill="black"
fill-opacity="0.4"
fill-rule="evenodd"
/>
<polygon
:points="polygonPoints"
fill="none"
stroke="white"
stroke-width="0.4"
vector-effect="non-scaling-stroke"
/>
</svg>
<div
v-for="(corner, cornerIndex) in displayCorners"
:key="cornerIndex"
class="absolute w-5 h-5 -ml-2.5 -mt-2.5 rounded-full bg-white border-2 border-neutral-600 cursor-move shadow"
:style="{ left: `${corner.x * 100}%`, top: `${corner.y * 100}%` }"
@mousedown.prevent="onCornerMousedown(cornerIndex)"
/>
</div>
</div>
<div class="w-56 flex-none border-l px-4 py-4 space-y-2 flex flex-col">
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!!isProcessing"
@click.prevent="submit(true)"
>
<IconInnerShadowTop
v-if="isProcessing === 'scan'"
class="w-4 h-4 animate-spin"
/>
<IconScan
v-else
class="w-4 h-4"
/>
{{ t('crop_and_scan') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!!isProcessing"
@click.prevent="submit(false)"
>
<IconInnerShadowTop
v-if="isProcessing === 'crop'"
class="w-4 h-4 animate-spin"
/>
<IconCrop
v-else
width="22"
height="22"
style="margin-left: -3px"
:stroke-width="1.5"
/>
<span :style="{ 'margin-left': isProcessing === 'crop' ? '0px' : '-3px' }">
{{ t('crop') }}
</span>
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
@click.prevent="$emit('cancel')"
>
<IconX class="w-4 h-4" />
{{ t('cancel') }}
</button>
<div class="border-t !mt-3 !mb-1" />
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
@click.prevent="rotateCw"
>
<IconRotateClockwise class="w-4 h-4" />
{{ t('rotate') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
:class="{ 'btn-active': flipH }"
@click.prevent="toggleFlip('flipH')"
>
<IconFlipVertical class="w-4 h-4" />
{{ t('flip_horizontal') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
:class="{ 'btn-active': flipV }"
@click.prevent="toggleFlip('flipV')"
>
<IconFlipHorizontal class="w-4 h-4" />
{{ t('flip_vertical') }}
</button>
</div>
</div>
</template>
<script>
import { IconCrop, IconScan, IconInnerShadowTop, IconX, IconRotateClockwise, IconFlipHorizontal, IconFlipVertical } from '@tabler/icons-vue'
export default {
name: 'DocumentsEditorCrop',
components: {
IconCrop,
IconScan,
IconInnerShadowTop,
IconX,
IconRotateClockwise,
IconFlipHorizontal,
IconFlipVertical
},
inject: ['t', 'baseFetch', 'isInlineSize'],
props: {
templateId: {
type: [Number, String],
required: true
},
page: {
type: Object,
required: true
},
imageUrl: {
type: String,
required: true
},
metadata: {
type: Object,
required: true
}
},
emits: ['apply', 'cancel'],
data () {
return {
corners: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 1 }
],
cornersTouched: false,
rotate: this.page.rotate || 0,
flipH: false,
flipV: false,
draggingIndex: null,
isProcessing: null
}
},
computed: {
displayWidth () {
return this.rotate % 180 ? this.metadata.height : this.metadata.width
},
displayHeight () {
return this.rotate % 180 ? this.metadata.width : this.metadata.height
},
pageStyle () {
const ratio = this.displayWidth / this.displayHeight
return {
aspectRatio: `${this.displayWidth} / ${this.displayHeight}`,
width: this.isInlineSize ? `min(100cqw, calc(100cqh * ${ratio}))` : `min(100%, calc(78vh * ${ratio}))`
}
},
imageStyle () {
const scale = this.rotate % 180 ? this.metadata.width / this.metadata.height : 1
const scaleX = (this.flipH ? -1 : 1) * scale
const scaleY = (this.flipV ? -1 : 1) * scale
return {
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${scaleX}, ${scaleY})`
}
},
displayCorners () {
return this.corners.map((corner) => this.transformPoint(corner, this.rotate, this.flipH, this.flipV))
},
polygonPoints () {
return this.displayCorners.map((corner) => `${corner.x * 100},${corner.y * 100}`).join(' ')
},
dimPath () {
const quad = this.displayCorners.map((corner) => `${corner.x * 100} ${corner.y * 100}`)
return `M 0 0 H 100 V 100 H 0 Z M ${quad.join(' L ')} Z`
}
},
created () {
const query = new URLSearchParams({ attachment_uuid: this.page.sourceUuid })
this.baseFetch(`/templates/${this.templateId}/documents_crop?${query}`).then(async (resp) => {
if (resp.ok) {
const data = await resp.json()
if (data.corners?.length === 4 && !this.cornersTouched) {
this.corners = data.corners.map((corner) => ({ x: corner.x, y: corner.y }))
}
}
})
},
beforeUnmount () {
window.removeEventListener('mousemove', this.onMousemove)
window.removeEventListener('mouseup', this.onMouseup)
},
methods: {
transformPoint (point, rotate, flipH, flipV) {
let { x, y } = point
if (flipH) {
x = 1 - x
}
if (flipV) {
y = 1 - y
}
if (rotate === 90) {
return { x: 1 - y, y: x }
} else if (rotate === 180) {
return { x: 1 - x, y: 1 - y }
} else if (rotate === 270) {
return { x: y, y: 1 - x }
} else {
return { x, y }
}
},
inverseTransformPoint (point, rotate, flipH, flipV) {
let { x, y } = point
if (rotate === 90) {
[x, y] = [y, 1 - x]
} else if (rotate === 180) {
[x, y] = [1 - x, 1 - y]
} else if (rotate === 270) {
[x, y] = [1 - y, x]
}
if (flipH) {
x = 1 - x
}
if (flipV) {
y = 1 - y
}
return { x, y }
},
rotateCw () {
this.rotate = (this.rotate + 90) % 360
},
toggleFlip (key) {
this[key] = !this[key]
},
pagePoint (event) {
const rect = this.$refs.pageEl.getBoundingClientRect()
return {
x: Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1),
y: Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1)
}
},
onCornerMousedown (index) {
this.draggingIndex = index
this.cornersTouched = true
window.addEventListener('mousemove', this.onMousemove)
window.addEventListener('mouseup', this.onMouseup, { once: true })
},
onMousemove (event) {
if (this.draggingIndex === null) {
return
}
const point = this.inverseTransformPoint(this.pagePoint(event), this.rotate, this.flipH, this.flipV)
this.corners[this.draggingIndex] = point
},
onMouseup () {
window.removeEventListener('mousemove', this.onMousemove)
this.draggingIndex = null
},
submit (scan) {
this.isProcessing = scan ? 'scan' : 'crop'
this.baseFetch(`/templates/${this.templateId}/documents_crop`, {
method: 'POST',
body: JSON.stringify({
attachment_uuid: this.page.sourceUuid,
corners: this.corners,
rotate: this.rotate || undefined,
flip_h: this.flipH,
flip_v: this.flipV,
scan
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
const data = await resp.json().catch(() => ({}))
if (resp.ok) {
this.$emit('apply', data.document)
} else if (data.error) {
alert(data.error)
}
}).finally(() => {
this.isProcessing = null
})
}
}
}
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
<template>
<div class="relative">
<div
class="relative"
:style="boxStyle"
>
<img
:src="imageUrl"
:width="metadata.width"
:height="metadata.height"
class="rounded border pointer-events-none outline outline-1 -outline-offset-1 transition-[outline-color] duration-75"
:class="[
page.rotate % 180 ? 'absolute inset-0 m-auto w-full' : 'w-full',
selected ? 'outline-neutral-400' : 'outline-transparent'
]"
:style="imageStyle"
:loading="lazy ? 'lazy' : 'eager'"
>
<div
v-if="areas.length || page.redact.length"
class="absolute pointer-events-none"
:style="overlayStyle"
>
<div
v-for="(item, areaIndex) in areas"
:key="areaIndex"
class="absolute border rounded-sm opacity-70"
:class="[areaBorderColor(item.submitterIndex), areaBgColor(item.submitterIndex)]"
:style="{
left: `${item.area.x * 100}%`,
top: `${item.area.y * 100}%`,
width: `${item.area.w * 100}%`,
height: `${item.area.h * 100}%`
}"
/>
<div
v-for="(rect, rectIndex) in page.redact"
:key="`redact-${rectIndex}`"
class="absolute bg-black"
:style="{
left: `${rect.x * 100}%`,
top: `${rect.y * 100}%`,
width: `${rect.w * 100}%`,
height: `${rect.h * 100}%`
}"
/>
</div>
<div
v-if="withActions"
class="absolute top-1 right-1 flex space-x-1 group-hover:opacity-100"
:class="selected ? 'opacity-100' : 'opacity-0'"
>
<span
v-if="extraAction"
class="tooltip tooltip-bottom"
:data-tip="t(extraAction)"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
style="width: 22px; height: 22px; min-height: 22px"
@click.stop.prevent="$emit(extraAction)"
>
<IconEraser
v-if="extraAction === 'redact'"
:width="14"
:height="14"
:stroke-width="1.6"
/>
<IconCrop
v-else
:width="20"
:height="20"
:stroke-width="1.2"
/>
</button>
</span>
<span
class="tooltip tooltip-bottom"
:data-tip="t('rotate')"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
style="width: 22px; height: 22px; min-height: 22px"
@click.stop.prevent="$emit('rotate')"
>
<IconRotateClockwise
:width="14"
:height="14"
:stroke-width="1.6"
/>
</button>
</span>
<span
class="tooltip tooltip-bottom"
:data-tip="t('remove')"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
style="width: 22px; height: 22px; min-height: 22px"
@click.stop.prevent="$emit('remove')"
>
<IconX
:width="14"
:height="14"
:stroke-width="1.6"
/>
</button>
</span>
</div>
</div>
<div
v-if="pageNumber"
class="text-center text-sm pt-1 pointer-events-none"
>
{{ t('page') }} {{ pageNumber }}
</div>
</div>
</template>
<script>
import { IconRotateClockwise, IconX, IconEraser, IconCrop } from '@tabler/icons-vue'
import Area from './area.vue'
export default {
name: 'DocumentsEditorPage',
components: {
IconRotateClockwise,
IconX,
IconEraser,
IconCrop
},
inject: ['t'],
props: {
page: {
type: Object,
required: true
},
imageUrl: {
type: String,
required: true
},
metadata: {
type: Object,
required: true
},
areas: {
type: Array,
required: false,
default: () => []
},
selected: {
type: Boolean,
required: false,
default: false
},
withActions: {
type: Boolean,
required: false,
default: false
},
pageNumber: {
type: Number,
required: false,
default: null
},
lazy: {
type: Boolean,
required: false,
default: true
},
extraAction: {
type: String,
required: false,
default: null
}
},
emits: ['rotate', 'remove', 'redact', 'crop'],
computed: {
borderColors: Area.computed.borderColors,
bgColors: Area.computed.bgColors,
boxStyle () {
if (!this.page.rotate || !(this.page.rotate % 180)) {
return null
}
return { aspectRatio: `${this.metadata.height} / ${this.metadata.width}` }
},
imageStyle () {
if (!this.page.rotate) {
return null
}
let transform = `rotate(${this.page.rotate}deg)`
if (this.page.rotate % 180) {
transform += ` scale(${this.metadata.width / this.metadata.height})`
}
return { transform }
},
overlayStyle () {
if (!this.page.rotate || !(this.page.rotate % 180)) {
return { inset: '0', transform: this.page.rotate ? `rotate(${this.page.rotate}deg)` : undefined }
}
return {
left: '50%',
top: '50%',
width: '100%',
aspectRatio: `${this.metadata.width} / ${this.metadata.height}`,
transform: `translate(-50%, -50%) rotate(${this.page.rotate}deg) scale(${this.metadata.width / this.metadata.height})`
}
}
},
methods: {
areaBorderColor (submitterIndex) {
return this.borderColors[Math.max(submitterIndex, 0) % this.borderColors.length]
},
areaBgColor (submitterIndex) {
return this.bgColors[Math.max(submitterIndex, 0) % this.bgColors.length]
}
}
}
</script>
@@ -0,0 +1,413 @@
<template>
<div class="flex flex-1 min-h-0">
<div class="flex-1 overflow-y-auto px-6 py-4">
<div
ref="pageEl"
class="relative mx-auto select-none cursor-crosshair"
:style="pageStyle"
@mousedown.prevent="onMousedown"
>
<img
:src="imageUrl"
:width="metadata.width"
:height="metadata.height"
class="absolute border rounded pointer-events-none"
style="left: 50%; top: 50%; width: 100%; height: auto"
:style="imageStyle"
>
<div
class="absolute pointer-events-none"
:style="overlayStyle"
>
<div
v-for="(rect, rectIndex) in redactRects"
:key="`rect-${rectIndex}`"
class="absolute bg-black pointer-events-none"
:style="{
left: `${rect.x * 100}%`,
top: `${rect.y * 100}%`,
width: `${rect.w * 100}%`,
height: `${rect.h * 100}%`
}"
/>
</div>
<div
v-if="marquee"
class="absolute border border-neutral-600 bg-neutral-600/10 pointer-events-none"
:style="marqueeStyle"
/>
<div
v-if="!imagePage && textNodes && !textNodes.length && !imageNodes.length"
class="absolute inset-x-0 top-0 flex justify-center pt-4 pointer-events-none"
>
<span class="bg-base-100/90 border border-neutral-200 rounded-lg shadow px-4 py-2 text-sm">
{{ t('there_is_no_text_to_redact_on_this_page') }}
</span>
</div>
</div>
</div>
<div class="w-56 flex-none border-l px-4 py-4 space-y-2">
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!hasRedactions && !wasReset"
@click.prevent="apply"
>
<IconCheck class="w-4 h-4" />
{{ t('apply') }}
</button>
<div class="border-t !mt-3 !mb-1" />
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!hasRedactions"
@click.prevent="reset"
>
<IconRotate class="w-4 h-4" />
{{ t('reset') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
@click.prevent="$emit('cancel')"
>
<IconX class="w-4 h-4" />
{{ t('cancel') }}
</button>
</div>
</div>
</template>
<script>
import { IconCheck, IconRotate, IconX } from '@tabler/icons-vue'
export default {
name: 'DocumentsEditorRedact',
components: {
IconCheck,
IconRotate,
IconX
},
inject: ['t', 'baseFetch'],
props: {
templateId: {
type: [Number, String],
required: true
},
page: {
type: Object,
required: true
},
imageUrl: {
type: String,
required: true
},
metadata: {
type: Object,
required: true
},
imagePage: {
type: Boolean,
required: false,
default: false
},
pageObjectsCache: {
type: Object,
required: false,
default: () => ({})
}
},
emits: ['apply', 'cancel'],
data () {
return {
textNodes: null,
imageNodes: [],
selectedNodes: {},
freeRects: [],
rects: [],
wasReset: false,
marquee: null
}
},
computed: {
rotate () {
return this.page.rotate || 0
},
displayWidth () {
return this.rotate % 180 ? this.metadata.height : this.metadata.width
},
displayHeight () {
return this.rotate % 180 ? this.metadata.width : this.metadata.height
},
pageStyle () {
return { aspectRatio: `${this.displayWidth} / ${this.displayHeight}` }
},
imageStyle () {
const scale = this.rotate % 180 ? this.metadata.width / this.metadata.height : 1
return {
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${scale})`
}
},
overlayStyle () {
if (!this.rotate || !(this.rotate % 180)) {
return { inset: '0', transform: this.rotate ? `rotate(${this.rotate}deg)` : undefined }
}
return {
left: '50%',
top: '50%',
width: '100%',
aspectRatio: `${this.metadata.width} / ${this.metadata.height}`,
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${this.metadata.width / this.metadata.height})`
}
},
hasRedactions () {
if (this.imagePage) {
return this.rects.length > 0
}
return Object.keys(this.selectedNodes).length > 0 || this.freeRects.length > 0
},
redactRects () {
if (this.imagePage) {
return this.rects
}
return this.textNodes ? [...this.buildRedactRects(), ...this.freeRects] : []
},
marqueeStyle () {
const left = Math.min(this.marquee.x1, this.marquee.x2)
const top = Math.min(this.marquee.y1, this.marquee.y2)
return {
left: `${left * 100}%`,
top: `${top * 100}%`,
width: `${Math.abs(this.marquee.x2 - this.marquee.x1) * 100}%`,
height: `${Math.abs(this.marquee.y2 - this.marquee.y1) * 100}%`
}
}
},
created () {
if (this.imagePage) {
this.rects = (this.page.redact || []).map((rect) => ({ ...rect }))
return
}
const cacheKey = `${this.page.sourceUuid}-${this.page.sourcePage}`
if (this.pageObjectsCache[cacheKey]) {
this.textNodes = this.pageObjectsCache[cacheKey].text_nodes
this.imageNodes = this.pageObjectsCache[cacheKey].image_nodes
this.preselectNodes()
return
}
const query = new URLSearchParams({ attachment_uuid: this.page.sourceUuid, page: this.page.sourcePage })
this.baseFetch(`/templates/${this.templateId}/documents_page_objects?${query}`).then(async (resp) => {
if (resp.ok) {
const data = await resp.json()
this.pageObjectsCache[cacheKey] = data
this.textNodes = data.text_nodes
this.imageNodes = data.image_nodes
this.preselectNodes()
} else {
this.$emit('cancel')
}
})
},
beforeUnmount () {
window.removeEventListener('mousemove', this.onMousemove)
window.removeEventListener('mouseup', this.onMouseup)
},
methods: {
inverseRotatePoint (point, rotate) {
let { x, y } = point
if (rotate === 90) {
[x, y] = [y, 1 - x]
} else if (rotate === 180) {
[x, y] = [1 - x, 1 - y]
} else if (rotate === 270) {
[x, y] = [1 - y, x]
}
return { x, y }
},
apply () {
this.$emit('apply', this.redactRects)
},
reset () {
this.wasReset = true
if (this.imagePage) {
this.rects = []
} else {
this.selectedNodes = {}
this.freeRects = []
}
},
boxesIntersect (a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
},
preselectNodes () {
const nodeRects = []
;(this.page.redact || []).forEach((rect) => {
if (this.imageNodes.some((node) => this.boxesIntersect(node, rect))) {
this.freeRects.push({ ...rect })
} else {
nodeRects.push(rect)
}
})
this.textNodes.forEach((node, index) => {
const centerX = node.x + (node.w / 2)
const centerY = node.y + (node.h / 2)
const isInside = nodeRects.some((rect) => {
return centerX >= rect.x && centerX <= rect.x + rect.w &&
centerY >= rect.y && centerY <= rect.y + rect.h
})
if (isInside) {
this.selectedNodes[index] = true
}
})
},
buildRedactRects () {
const nodes = this.textNodes.filter((_, index) => this.selectedNodes[index])
const sorted = nodes.slice().sort((a, b) => {
const diff = (a.y + (a.h / 2)) - (b.y + (b.h / 2))
return Math.abs(diff) < Math.min(a.h, b.h) / 2 ? a.x - b.x : diff
})
const rects = []
sorted.forEach((node) => {
const last = rects[rects.length - 1]
const sameLine = last &&
Math.abs((node.y + (node.h / 2)) - (last.y + (last.h / 2))) < Math.max(last.h, node.h) / 2
if (sameLine && node.x <= last.x + last.w + (node.h * 0.7)) {
const right = Math.max(last.x + last.w, node.x + node.w)
const bottom = Math.max(last.y + last.h, node.y + node.h)
last.x = Math.min(last.x, node.x)
last.y = Math.min(last.y, node.y)
last.w = right - last.x
last.h = bottom - last.y
} else {
rects.push({ x: node.x, y: node.y, w: node.w, h: node.h })
}
})
return rects
},
pagePoint (event) {
const rect = this.$refs.pageEl.getBoundingClientRect()
return {
x: Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1),
y: Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1)
}
},
onMousedown (event) {
if (event.button !== 0 || (!this.imagePage && !this.textNodes)) {
return
}
const point = this.pagePoint(event)
this.marquee = { x1: point.x, y1: point.y, x2: point.x, y2: point.y }
window.addEventListener('mousemove', this.onMousemove)
window.addEventListener('mouseup', this.onMouseup, { once: true })
},
onMousemove (event) {
const point = this.pagePoint(event)
this.marquee.x2 = point.x
this.marquee.y2 = point.y
},
onMouseup () {
window.removeEventListener('mousemove', this.onMousemove)
if (!this.marquee) {
return
}
const start = this.inverseRotatePoint({ x: this.marquee.x1, y: this.marquee.y1 }, this.rotate)
const finish = this.inverseRotatePoint({ x: this.marquee.x2, y: this.marquee.y2 }, this.rotate)
const left = Math.min(start.x, finish.x)
const right = Math.max(start.x, finish.x)
const top = Math.min(start.y, finish.y)
const bottom = Math.max(start.y, finish.y)
this.marquee = null
if (right - left < 0.005 && bottom - top < 0.005) {
if (this.imagePage) {
const index = this.rects.findIndex((rect) => {
return left >= rect.x && left <= rect.x + rect.w && top >= rect.y && top <= rect.y + rect.h
})
if (index !== -1) {
this.rects.splice(index, 1)
}
} else {
const rectIndex = this.freeRects.findIndex((rect) => {
return left >= rect.x && left <= rect.x + rect.w && top >= rect.y && top <= rect.y + rect.h
})
if (rectIndex !== -1) {
this.freeRects.splice(rectIndex, 1)
} else {
const index = this.textNodes.findIndex((node) => {
return left >= node.x && left <= node.x + node.w && top >= node.y && top <= node.y + node.h
})
if (index !== -1) {
if (this.selectedNodes[index]) {
delete this.selectedNodes[index]
} else {
this.selectedNodes[index] = true
}
}
}
}
} else if (this.imagePage) {
this.rects.push({ x: left, y: top, w: right - left, h: bottom - top })
} else {
const marqueeBox = { x: left, y: top, w: right - left, h: bottom - top }
this.textNodes.forEach((node, index) => {
if (node.x < right && node.x + node.w > left && node.y < bottom && node.y + node.h > top) {
this.selectedNodes[index] = true
}
})
this.imageNodes.forEach((node) => {
if (!this.boxesIntersect(node, marqueeBox)) {
return
}
const x = Math.max(node.x, marqueeBox.x)
const y = Math.max(node.y, marqueeBox.y)
const w = Math.min(node.x + node.w, marqueeBox.x + marqueeBox.w) - x
const h = Math.min(node.y + node.h, marqueeBox.y + marqueeBox.h) - y
this.freeRects.push({ x, y, w, h })
})
}
}
}
}
</script>
+119
View File
@@ -96,6 +96,23 @@ const en = {
add_pdf_documents_or_images: 'Add PDF documents or images',
add_documents_or_images: 'Add documents or images',
add_a_new_document: 'Add a new document',
edit_documents: 'Edit documents',
move_forward: 'Move forward',
move_backward: 'Move backward',
remove_page: 'Remove page',
merge_with_previous: 'Merge with previous',
merge_with_next: 'Merge with next',
move_up: 'Move up',
move_down: 'Move down',
rotate: 'Rotate',
redact: 'Redact',
crop: 'Crop',
crop_and_scan: 'Crop & Scan',
flip_horizontal: 'Flip horizontal',
flip_vertical: 'Flip vertical',
there_is_no_text_to_redact_on_this_page: 'This page contains only images. Redact tool can be used only with text pages',
reset: 'Reset',
upload_to_document: 'Upload to "{document}"',
replace_existing_document: 'Replace existing document',
clone_and_replace_documents: 'Clone and replace documents',
required: 'Required',
@@ -319,6 +336,23 @@ const es = {
add_pdf_documents_or_images: 'Agregar documentos PDF o imágenes',
add_documents_or_images: 'Agregar documentos o imágenes',
add_a_new_document: 'Agregar un nuevo documento',
edit_documents: 'Editar documentos',
move_forward: 'Mover adelante',
move_backward: 'Mover atrás',
remove_page: 'Eliminar página',
merge_with_previous: 'Combinar con el anterior',
merge_with_next: 'Combinar con el siguiente',
move_up: 'Mover arriba',
move_down: 'Mover abajo',
rotate: 'Rotar',
redact: 'Censurar',
crop: 'Recortar',
crop_and_scan: 'Recortar y escanear',
flip_horizontal: 'Voltear horizontal',
flip_vertical: 'Voltear vertical',
there_is_no_text_to_redact_on_this_page: 'Esta página contiene solo imágenes. La herramienta de censura solo puede usarse con páginas de texto',
reset: 'Restablecer',
upload_to_document: 'Subir a "{document}"',
replace_existing_document: 'Reemplazar documento existente',
clone_and_replace_documents: 'Clonar y reemplazar documentos',
required: 'Requerido',
@@ -548,6 +582,23 @@ const it = {
add_pdf_documents_or_images: 'Aggiungi documenti PDF o immagini',
add_documents_or_images: 'Aggiungi documenti o immagini',
add_a_new_document: 'Aggiungi un nuovo documento',
edit_documents: 'Modifica documenti',
move_forward: 'Sposta avanti',
move_backward: 'Sposta indietro',
remove_page: 'Rimuovi pagina',
merge_with_previous: 'Unisci al precedente',
merge_with_next: 'Unisci al successivo',
move_up: 'Sposta su',
move_down: 'Sposta giù',
rotate: 'Ruota',
redact: 'Oscura',
crop: 'Ritaglia',
crop_and_scan: 'Ritaglia e scansiona',
flip_horizontal: 'Rifletti orizzontale',
flip_vertical: 'Rifletti verticale',
there_is_no_text_to_redact_on_this_page: 'Questa pagina contiene solo immagini. Lo strumento di oscuramento può essere usato solo con pagine di testo',
reset: 'Reimposta',
upload_to_document: 'Carica in "{document}"',
replace_existing_document: 'Sostituisci documento esistente',
clone_and_replace_documents: 'Clona e sostituisci documenti',
required: 'Obbligatorio',
@@ -771,6 +822,23 @@ const pt = {
add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens',
add_documents_or_images: 'Adicionar documentos ou imagens',
add_a_new_document: 'Adicionar um novo documento',
edit_documents: 'Editar documentos',
move_forward: 'Mover para frente',
move_backward: 'Mover para trás',
remove_page: 'Remover página',
merge_with_previous: 'Mesclar com o anterior',
merge_with_next: 'Mesclar com o próximo',
move_up: 'Mover para cima',
move_down: 'Mover para baixo',
rotate: 'Girar',
redact: 'Censurar',
crop: 'Cortar',
crop_and_scan: 'Cortar e digitalizar',
flip_horizontal: 'Inverter horizontal',
flip_vertical: 'Inverter vertical',
there_is_no_text_to_redact_on_this_page: 'Esta página contém apenas imagens. A ferramenta de censura só pode ser usada com páginas de texto',
reset: 'Redefinir',
upload_to_document: 'Enviar para "{document}"',
replace_existing_document: 'Substituir documento existente',
clone_and_replace_documents: 'Clonar e substituir documentos',
required: 'Obrigatório',
@@ -1000,6 +1068,23 @@ const fr = {
add_pdf_documents_or_images: 'Ajouter des documents PDF ou des images',
add_documents_or_images: 'Ajouter des documents ou des images',
add_a_new_document: 'Ajouter un nouveau document',
edit_documents: 'Modifier les documents',
move_forward: 'Déplacer en avant',
move_backward: 'Déplacer en arrière',
remove_page: 'Supprimer la page',
merge_with_previous: 'Fusionner avec le précédent',
merge_with_next: 'Fusionner avec le suivant',
move_up: 'Déplacer vers le haut',
move_down: 'Déplacer vers le bas',
rotate: 'Pivoter',
redact: 'Caviarder',
crop: 'Rogner',
crop_and_scan: 'Rogner et numériser',
flip_horizontal: 'Miroir horizontal',
flip_vertical: 'Miroir vertical',
there_is_no_text_to_redact_on_this_page: 'Cette page ne contient que des images. L\'outil de caviardage ne peut être utilisé qu\'avec des pages de texte',
reset: 'Réinitialiser',
upload_to_document: 'Téléverser dans "{document}"',
replace_existing_document: 'Remplacer le document existant',
clone_and_replace_documents: 'Cloner et remplacer des documents',
required: 'Obligatoire',
@@ -1226,6 +1311,23 @@ const de = {
add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen',
add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
add_a_new_document: 'Neues Dokument hinzufügen',
edit_documents: 'Dokumente bearbeiten',
move_forward: 'Nach vorne',
move_backward: 'Nach hinten',
remove_page: 'Seite entfernen',
merge_with_previous: 'Mit vorherigem zusammenführen',
merge_with_next: 'Mit nächstem zusammenführen',
move_up: 'Nach oben verschieben',
move_down: 'Nach unten verschieben',
rotate: 'Drehen',
redact: 'Schwärzen',
crop: 'Zuschneiden',
crop_and_scan: 'Zuschneiden & Scan',
flip_horizontal: 'Horizontal spiegeln',
flip_vertical: 'Vertikal spiegeln',
there_is_no_text_to_redact_on_this_page: 'Diese Seite enthält nur Bilder. Das Schwärzungswerkzeug kann nur mit Textseiten verwendet werden',
reset: 'Zurücksetzen',
upload_to_document: 'In "{document}" hochladen',
replace_existing_document: 'Vorhandenes Dokument ersetzen',
clone_and_replace_documents: 'Dokumente klonen und ersetzen',
required: 'Erforderlich',
@@ -1452,6 +1554,23 @@ const nl = {
add_pdf_documents_or_images: 'PDF-documenten of afbeeldingen toevoegen',
add_documents_or_images: 'Documenten of afbeeldingen toevoegen',
add_a_new_document: 'Nieuw document toevoegen',
edit_documents: 'Documenten bewerken',
move_forward: 'Naar voren',
move_backward: 'Naar achteren',
remove_page: 'Pagina verwijderen',
merge_with_previous: 'Samenvoegen met vorige',
merge_with_next: 'Samenvoegen met volgende',
move_up: 'Omhoog verplaatsen',
move_down: 'Omlaag verplaatsen',
rotate: 'Draaien',
redact: 'Redigeren',
crop: 'Bijsnijden',
crop_and_scan: 'Bijsnijden en scannen',
flip_horizontal: 'Horizontaal spiegelen',
flip_vertical: 'Verticaal spiegelen',
there_is_no_text_to_redact_on_this_page: 'Deze pagina bevat alleen afbeeldingen. De redactietool kan alleen worden gebruikt met tekstpagina\'s',
reset: 'Opnieuw instellen',
upload_to_document: 'Uploaden naar "{document}"',
replace_existing_document: 'Bestaand document vervangen',
clone_and_replace_documents: 'Documenten klonen en vervangen',
required: 'Vereist',
+13 -3
View File
@@ -72,6 +72,15 @@
style="min-width: 170px"
@click="closeDropdown"
>
<li v-if="!item.dynamic">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
@click.stop="$emit('edit', item); closeDropdown()"
>
<IconPencil class="w-4 h-4" />
<span>{{ t('edit') }}</span>
</button>
</li>
<li>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@@ -184,7 +193,7 @@
<script>
import Contenteditable from './contenteditable'
import Upload from './upload'
import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop } from '@tabler/icons-vue'
import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop, IconPencil } from '@tabler/icons-vue'
import ConditionsModal from './conditions_modal'
import ReplaceButton from './replace'
import GoogleDriveDocumentSettings from './google_drive_document_settings'
@@ -204,7 +213,8 @@ export default {
IconSortDescending2,
IconDotsVertical,
IconTrashX,
IconBolt
IconBolt,
IconPencil
},
inject: ['t', 'getFieldTypeIndex', 'baseFetch'],
props: {
@@ -260,7 +270,7 @@ export default {
default: true
}
},
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder'],
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder', 'edit'],
data () {
return {
isShowConditionsModal: false,
+21 -13
View File
@@ -128,31 +128,39 @@ function convertImage (sourceFile, targetType, quality) {
})
}
async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
export async function convertUnsupportedImages (files) {
const converted = []
const dt = new DataTransfer()
let didConvert = false
for (const file of Array.from(input.files)) {
let converted = file
for (const file of Array.from(files)) {
let result = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
converted = await convertImage(file, 'image/png')
didConvert = true
result = await convertImage(file, 'image/png')
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
converted = await convertImage(file, 'image/jpeg', 0.9)
didConvert = true
result = await convertImage(file, 'image/jpeg', 0.9)
}
} catch (e) {
alert(e.message)
}
dt.items.add(converted)
converted.push(result)
}
if (didConvert) {
return converted
}
async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const originals = Array.from(input.files)
const converted = await convertUnsupportedImages(originals)
if (converted.some((file, index) => file !== originals[index])) {
const dt = new DataTransfer()
converted.forEach((file) => dt.items.add(file))
input.files = dt.files
}
}