mirror of https://github.com/docusealco/docuseal
commit
dfc12095ff
@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsResendEmailController < ApplicationController
|
||||
load_and_authorize_resource :submission
|
||||
|
||||
before_action do
|
||||
authorize!(:manage, :resend_all)
|
||||
authorize!(:update, @submission)
|
||||
end
|
||||
|
||||
def create
|
||||
submitters = @submission.submitters.reject(&:completed_at?).select { |s| s.email.present? && !s.declined_at? }
|
||||
|
||||
if Docuseal.multitenant?
|
||||
recent_submitter_ids =
|
||||
SubmissionEvent.where(submitter_id: submitters.map(&:id),
|
||||
event_type: 'send_email',
|
||||
created_at: 10.hours.ago..Time.current).pluck(:submitter_id).to_set
|
||||
|
||||
submitters = submitters.reject { |s| recent_submitter_ids.include?(s.id) }
|
||||
end
|
||||
|
||||
submitters.each do |submitter|
|
||||
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
|
||||
|
||||
submitter.sent_at ||= Time.current
|
||||
submitter.save!
|
||||
end
|
||||
|
||||
notice =
|
||||
if submitters.empty?
|
||||
I18n.t('email_has_been_sent_already')
|
||||
else
|
||||
I18n.t('emails_have_been_sent_to_n_recipients', count: submitters.size)
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: submission_path(@submission), notice:)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateDocumentsCropController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
before_action :load_attachment
|
||||
|
||||
rescue_from Leptonica::LeptonicaError do
|
||||
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def index
|
||||
render json: { corners: Leptonica.detect_document_corners(@attachment.download) }
|
||||
end
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
document = Templates::CreateDocumentCrop.call(@template, @attachment, crop_params)
|
||||
|
||||
render json: {
|
||||
document: document.as_json(
|
||||
methods: %i[metadata signed_key],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_attachment
|
||||
@attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
|
||||
end
|
||||
|
||||
def crop_params
|
||||
params.permit(:scan, :rotate, :flip_h, :flip_v, corners: [%i[x y]])
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateDocumentsModifyController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
documents_layout =
|
||||
params.require(:documents).map do |item|
|
||||
item.permit(:attachment_uuid,
|
||||
pages: [:attachment_uuid, :page, :rotate,
|
||||
{ redact: [%i[x y w h color]], replaced_page: %i[attachment_uuid page] }]).to_h
|
||||
end
|
||||
|
||||
Templates::ModifyDocuments.call(@template, documents_layout)
|
||||
|
||||
render json: {
|
||||
schema: @template.schema,
|
||||
fields: @template.fields,
|
||||
submitters: @template.submitters,
|
||||
documents: @template.schema_documents.reload.preload(:blob, preview_images_attachments: :blob).as_json(
|
||||
methods: %i[metadata signed_key],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
)
|
||||
}
|
||||
rescue Templates::ModifyDocuments::InvalidLayout
|
||||
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateDocumentsPageObjectsController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def index
|
||||
attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
|
||||
|
||||
render json: Templates::ModifyDocuments.page_objects(attachment, params[:page].to_i)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesSharedController < ApplicationController
|
||||
def index
|
||||
authorize!(:read, Template)
|
||||
|
||||
@is_archived = params[:archived] == 'true'
|
||||
|
||||
@templates = Templates.shared(current_user)
|
||||
|
||||
@has_archived = !@is_archived && @templates.archived.exists?
|
||||
|
||||
@templates = @is_archived ? @templates.archived : @templates.active
|
||||
|
||||
@templates = @templates.preload(:author, :template_accesses, :template_sharings)
|
||||
.order(id: :desc)
|
||||
|
||||
@templates = Templates.search_shared(current_user, @templates, params[:q])
|
||||
|
||||
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
|
||||
|
||||
return unless params[:q].present? && @templates.blank?
|
||||
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_related_submissions(is_archived:)
|
||||
shared_templates = Templates.shared(current_user)
|
||||
shared_templates = is_archived ? shared_templates.archived : shared_templates.active
|
||||
|
||||
related_submissions =
|
||||
Submission.accessible_by(current_ability)
|
||||
.where(template_id: shared_templates.select(:id))
|
||||
.preload(:template_accesses, :created_by_user,
|
||||
template: :author,
|
||||
submitters: :start_form_submission_events)
|
||||
|
||||
related_submissions = related_submissions.where(archived_at: nil) unless is_archived
|
||||
|
||||
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
|
||||
.order(id: :desc)
|
||||
|
||||
pagy_auto(related_submissions.select_for_list, limit: 5)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,27 @@
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
|
||||
export default targetable(class extends HTMLElement {
|
||||
static [target.static] = [
|
||||
'prompt',
|
||||
'processing',
|
||||
'logo'
|
||||
]
|
||||
|
||||
connectedCallback () {
|
||||
this.form.addEventListener('submit', this.onSubmit)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.form.removeEventListener('submit', this.onSubmit)
|
||||
}
|
||||
|
||||
onSubmit = () => {
|
||||
this.prompt.classList.add('hidden')
|
||||
this.processing.classList.remove('hidden')
|
||||
this.logo.classList.add('animate-bounce')
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.querySelector('form')
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,73 @@
|
||||
export function convertImage (sourceFile, targetType, quality) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = function (event) {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = function () {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
ctx.drawImage(img, 0, 0)
|
||||
canvas.toBlob(function (blob) {
|
||||
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
|
||||
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
|
||||
resolve(newFile)
|
||||
}, targetType, quality)
|
||||
}
|
||||
|
||||
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
|
||||
|
||||
img.src = event.target.result
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
export async function convertImagesInInput (input) {
|
||||
if (!input.files || input.files.length === 0) return
|
||||
|
||||
const dt = new DataTransfer()
|
||||
let didConvert = false
|
||||
|
||||
for (const file of Array.from(input.files)) {
|
||||
let converted = 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
|
||||
} 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
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e.message)
|
||||
}
|
||||
|
||||
dt.items.add(converted)
|
||||
}
|
||||
|
||||
if (didConvert) {
|
||||
input.files = dt.files
|
||||
}
|
||||
}
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const input = this.querySelector('input[type="file"]')
|
||||
const form = input.form
|
||||
|
||||
input.addEventListener('change', async () => {
|
||||
await convertImagesInInput(input)
|
||||
|
||||
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
|
||||
|
||||
form.requestSubmit()
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const input = this.querySelector('input')
|
||||
const invalidMessage = this.dataset.invalidMessage || ''
|
||||
|
||||
input.addEventListener('invalid', () => {
|
||||
input.setCustomValidity(input.value ? invalidMessage : '')
|
||||
})
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
input.setCustomValidity('')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,353 @@
|
||||
<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)"
|
||||
@touchstart.prevent="onCornerTouchstart(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)
|
||||
window.removeEventListener('touchmove', this.onTouchmove)
|
||||
window.removeEventListener('touchend', this.onTouchend)
|
||||
window.removeEventListener('touchcancel', this.onTouchend)
|
||||
},
|
||||
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)
|
||||
}
|
||||
},
|
||||
startCornerDrag (index) {
|
||||
this.draggingIndex = index
|
||||
this.cornersTouched = true
|
||||
},
|
||||
dragCorner (point) {
|
||||
if (this.draggingIndex === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.corners[this.draggingIndex] = this.inverseTransformPoint(this.pagePoint(point), this.rotate, this.flipH, this.flipV)
|
||||
},
|
||||
onCornerMousedown (index) {
|
||||
this.startCornerDrag(index)
|
||||
|
||||
window.addEventListener('mousemove', this.onMousemove)
|
||||
window.addEventListener('mouseup', this.onMouseup, { once: true })
|
||||
},
|
||||
onMousemove (event) {
|
||||
this.dragCorner(event)
|
||||
},
|
||||
onMouseup () {
|
||||
window.removeEventListener('mousemove', this.onMousemove)
|
||||
|
||||
this.draggingIndex = null
|
||||
},
|
||||
onCornerTouchstart (index) {
|
||||
this.startCornerDrag(index)
|
||||
|
||||
window.addEventListener('touchmove', this.onTouchmove, { passive: false })
|
||||
window.addEventListener('touchend', this.onTouchend)
|
||||
window.addEventListener('touchcancel', this.onTouchend)
|
||||
},
|
||||
onTouchmove (event) {
|
||||
this.dragCorner(event.touches[0])
|
||||
},
|
||||
onTouchend () {
|
||||
window.removeEventListener('touchmove', this.onTouchmove)
|
||||
window.removeEventListener('touchend', this.onTouchend)
|
||||
window.removeEventListener('touchcancel', this.onTouchend)
|
||||
|
||||
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,225 @@
|
||||
<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"
|
||||
:class="rect.color === 'white' ? 'bg-white' : '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,490 @@
|
||||
<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"
|
||||
@touchstart.prevent="onTouchstart"
|
||||
>
|
||||
<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 pointer-events-none"
|
||||
:class="color === 'white' ? 'bg-white' : 'bg-black'"
|
||||
: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">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm pl-1">{{ t('color') }}</span>
|
||||
<div
|
||||
class="join rounded"
|
||||
style="height: 28px"
|
||||
>
|
||||
<button
|
||||
v-for="option in colors"
|
||||
:key="option"
|
||||
class="btn btn-sm join-item !h-7 !min-h-0 bg-white input-bordered hover:border-base-content/20 hover:bg-base-100/50 px-2"
|
||||
:class="{ '!bg-base-200': color === option }"
|
||||
@click.prevent="color = option"
|
||||
>
|
||||
<span
|
||||
class="block w-10 h-4 border border-base-content/30"
|
||||
:style="{ backgroundColor: option }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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: [],
|
||||
color: 'black',
|
||||
wasReset: false,
|
||||
marquee: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
colors () {
|
||||
return ['black', 'white']
|
||||
},
|
||||
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.page.redact || []).some((rect) => rect.color === 'white')) {
|
||||
this.color = 'white'
|
||||
}
|
||||
|
||||
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)
|
||||
window.removeEventListener('touchmove', this.onTouchmove)
|
||||
window.removeEventListener('touchend', this.onTouchend)
|
||||
window.removeEventListener('touchcancel', this.onTouchend)
|
||||
},
|
||||
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 () {
|
||||
const rects = this.redactRects.map((rect) => {
|
||||
const next = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }
|
||||
|
||||
if (this.color === 'white') {
|
||||
next.color = 'white'
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
this.$emit('apply', rects)
|
||||
},
|
||||
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)
|
||||
}
|
||||
},
|
||||
startMarquee (point) {
|
||||
const start = this.pagePoint(point)
|
||||
|
||||
this.marquee = { x1: start.x, y1: start.y, x2: start.x, y2: start.y }
|
||||
},
|
||||
updateMarquee (point) {
|
||||
if (!this.marquee) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = this.pagePoint(point)
|
||||
|
||||
this.marquee.x2 = next.x
|
||||
this.marquee.y2 = next.y
|
||||
},
|
||||
onMousedown (event) {
|
||||
if (event.button !== 0 || (!this.imagePage && !this.textNodes)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.startMarquee(event)
|
||||
|
||||
window.addEventListener('mousemove', this.onMousemove)
|
||||
window.addEventListener('mouseup', this.onMouseup, { once: true })
|
||||
},
|
||||
onMousemove (event) {
|
||||
this.updateMarquee(event)
|
||||
},
|
||||
onMouseup () {
|
||||
window.removeEventListener('mousemove', this.onMousemove)
|
||||
|
||||
this.finishMarquee()
|
||||
},
|
||||
onTouchstart (event) {
|
||||
if (!this.imagePage && !this.textNodes) {
|
||||
return
|
||||
}
|
||||
|
||||
this.startMarquee(event.touches[0])
|
||||
|
||||
window.addEventListener('touchmove', this.onTouchmove, { passive: false })
|
||||
window.addEventListener('touchend', this.onTouchend)
|
||||
window.addEventListener('touchcancel', this.onTouchend)
|
||||
},
|
||||
onTouchmove (event) {
|
||||
this.updateMarquee(event.touches[0])
|
||||
},
|
||||
onTouchend () {
|
||||
window.removeEventListener('touchmove', this.onTouchmove)
|
||||
window.removeEventListener('touchend', this.onTouchend)
|
||||
window.removeEventListener('touchcancel', this.onTouchend)
|
||||
|
||||
this.finishMarquee()
|
||||
},
|
||||
finishMarquee () {
|
||||
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>
|
||||
@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<Teleport :to="modalContainerEl">
|
||||
<div
|
||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span class="modal-title">
|
||||
Google Drive
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl modal-close-button"
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<div>
|
||||
<form
|
||||
v-if="showOauthButton"
|
||||
method="post"
|
||||
:action="oauthPath"
|
||||
@submit="isConnectClicked = true"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticity_token"
|
||||
:value="authenticityToken"
|
||||
autocomplete="off"
|
||||
>
|
||||
<button
|
||||
class="btn bg-white btn-outline w-full text-base font-medium mt-4"
|
||||
data-turbo="false"
|
||||
type="submit"
|
||||
:disabled="isConnectClicked"
|
||||
>
|
||||
<span v-if="isConnectClicked">
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<IconInnerShadowTop class="animate-spin" />
|
||||
<span>{{ t('submitting') }}...</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="flex items-center justify-center space-x-2">
|
||||
<IconBrandGoogleDrive />
|
||||
<span>{{ t('connect_google_drive') }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
v-else
|
||||
class="relative"
|
||||
>
|
||||
<iframe
|
||||
class="border border-base-300 rounded-lg"
|
||||
style="width: 100%; height: 440px; background: white;"
|
||||
src="/template_google_drive"
|
||||
/>
|
||||
<div v-if="loading">
|
||||
<div
|
||||
class="bg-white absolute top-0 bottom-0 left-0 right-0 opacity-80 rounded-lg"
|
||||
style="margin: 1px"
|
||||
/>
|
||||
<div class="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center">
|
||||
<IconInnerShadowTop class="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconBrandGoogleDrive, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'GoogleDrivePickerModal',
|
||||
components: {
|
||||
IconBrandGoogleDrive,
|
||||
IconInnerShadowTop
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
reopenAfterAuth: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
modalContainerEl: {
|
||||
type: [Object, String],
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
emits: ['close', 'picked', 'update:loading'],
|
||||
data () {
|
||||
return {
|
||||
isConnectClicked: false,
|
||||
showOauthButton: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
oauthPath () {
|
||||
const redir = this.reopenAfterAuth
|
||||
? `/templates/${this.templateId}/edit?google_drive_open=1`
|
||||
: `/templates/${this.templateId}/edit`
|
||||
|
||||
const params = {
|
||||
access_type: 'offline',
|
||||
include_granted_scopes: 'true',
|
||||
prompt: 'consent',
|
||||
scope: [
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/drive.file'
|
||||
].join(' '),
|
||||
oauth_data: new URLSearchParams({ redir }).toString()
|
||||
}
|
||||
|
||||
return `/auth/google_oauth2?${new URLSearchParams(params).toString()}`
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
window.addEventListener('message', this.messageHandler)
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('message', this.messageHandler)
|
||||
},
|
||||
methods: {
|
||||
messageHandler (event) {
|
||||
if (event.data.type === 'google-drive-files-picked') {
|
||||
const files = event.data.files || []
|
||||
|
||||
if (!files.length) return
|
||||
|
||||
this.$emit('update:loading', true)
|
||||
this.$emit('picked', files)
|
||||
} else if (event.data.type === 'google-drive-picker-loaded') {
|
||||
this.$emit('update:loading', false)
|
||||
} else if (event.data.type === 'google-drive-picker-request-oauth') {
|
||||
this.showOauthButton = true
|
||||
this.$emit('update:loading', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
After Width: | Height: | Size: 662 B |
|
After Width: | Height: | Size: 447 B |
@ -1,6 +1,6 @@
|
||||
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
|
||||
<span class="flex items-center justify-between space-x-0.5 font-medium">
|
||||
<%= svg_icon('start', class: 'h-3 w-3') %>
|
||||
<span>16k</span>
|
||||
<span>17k</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue