Display PDF document with lib (#1996)

Signed-off-by: Vitor Mattos <vitor@php.rio>
This commit is contained in:
Vitor Mattos 2024-01-17 00:13:42 -03:00
parent 8dc227c26a
commit 5902e9c4a6
No known key found for this signature in database
GPG key ID: B7AB4B76A7CA7318
12 changed files with 1369 additions and 591 deletions

View file

@ -204,7 +204,7 @@ class PageController extends AEnvironmentPageAwareController {
#[NoCSRFRequired]
#[RequireSetupOk]
#[PublicPage]
#[AnonRateLimit(limit: 5, period: 120)]
#[AnonRateLimit(limit: 12, period: 60)]
public function getPdf($uuid) {
$this->throwIfValidationPageNotAccessible();
try {
@ -229,7 +229,7 @@ class PageController extends AEnvironmentPageAwareController {
#[RequireSignRequestUuid]
#[PublicPage]
#[RequireSetupOk]
#[AnonRateLimit(limit: 5, period: 120)]
#[AnonRateLimit(limit: 12, period: 60)]
public function getPdfUser($uuid) {
$this->throwIfValidationPageNotAccessible();
$resp = new FileDisplayResponse($this->getNextcloudFile());

View file

@ -241,7 +241,7 @@ class ValidateHelper {
throw new LibresignException($this->l10n->t('Coordinate %s must be an integer', [$type]));
}
if ($value < 0) {
throw new LibresignException($this->l10n->t('Coordinate %s must be equal to or greater than 0', [$type]));
throw new LibresignException($this->l10n->t('Out of marging'));
}
}
}

View file

@ -125,7 +125,7 @@ class FileElementService {
$dimension = $metadata['d'][$properties['coordinates']['page'] - 1];
$translated['left'] = $properties['coordinates']['llx'];
$translated['height'] = $properties['coordinates']['ury'] - $properties['coordinates']['lly'];
$translated['height'] = abs($properties['coordinates']['ury'] - $properties['coordinates']['lly']);
$translated['top'] = $dimension['h'] - $properties['coordinates']['ury'];
$translated['width'] = $properties['coordinates']['urx'] - $properties['coordinates']['llx'];
@ -134,7 +134,7 @@ class FileElementService {
public function deleteVisibleElement(int $elementId): void {
$fileElement = new FileElement();
$fileElement->fromRow(['id' => $elementId]);
$fileElement = $fileElement->fromRow(['id' => $elementId]);
$this->fileElementMapper->delete($fileElement);
}

View file

@ -270,9 +270,6 @@ class FileService {
private function getVisibleElements(): array {
$return = [];
try {
if ($this->me) {
$uid = $this->me->getUID();
}
if (is_object($this->signRequest)) {
$visibleElements = $this->fileElementMapper->getByFileIdAndSignRequestId($this->file->getId(), $this->signRequest->getId());
} else {
@ -291,14 +288,6 @@ class FileService {
'lly' => $visibleElement->getLly()
]
];
if (!empty($uid) && $uid === $this->file->getUserId()) {
$signRequest = $this->signRequestMapper->getById($visibleElement->getSignRequestId());
$userAssociatedToVisibleElement = $this->userManager->getByEmail($signRequest->getEmail());
if ($userAssociatedToVisibleElement) {
$element['uid'] = $userAssociatedToVisibleElement[0]->getUID();
}
$element['email'] = $signRequest->getEmail();
}
$element['coordinates'] = array_merge(
$element['coordinates'],
$this->fileElementService->translateCoordinatesFromInternalNotation($element, $this->file)

718
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@
"build": "webpack --node-env production --progress",
"dev": "webpack --node-env development --progress",
"watch": "webpack --node-env development --progress --watch",
"serve": "webpack serve --node-env development --progress --allowed-hosts all --host 0.0.0.0",
"lint": "eslint --ext .js,.vue src",
"lint:fix": "eslint --ext .js,.vue src --fix",
"stylelint": "stylelint css/*.css css/*.scss src/**/*.scss src/**/*.vue",
@ -18,6 +19,7 @@
},
"dependencies": {
"@fontsource/dancing-script": "^5.0.16",
"@libresign/vue-pdf-editor": "^1.2.2",
"@marionebl/option": "^1.0.8",
"@nextcloud/auth": "^2.1.0",
"@nextcloud/axios": "^2.4.0",

View file

@ -0,0 +1,383 @@
<template>
<NcModal v-if="open"
v-bind="modalProps"
class="modal-container__content"
@close="handleClosed"
@update:show="handleClosing">
<!-- The dialog name / header -->
<h2 :id="navigationId" class="dialog__name" v-text="name" />
<div class="dialog" :class="dialogClasses">
<div :class="['dialog__wrapper', { 'dialog__wrapper--collapsed': isNavigationCollapsed }]">
<!-- When the navigation is collapsed (too small dialog) it is displayed above the main content, otherwise on the inline start -->
<nav v-if="hasNavigation"
class="dialog__navigation"
:class="navigationClasses"
:aria-labelledby="navigationId">
<slot name="navigation" :is-collapsed="isNavigationCollapsed" />
</nav>
<!-- Main dialog content -->
<div class="dialog__content" :class="contentClasses">
<slot>
<p class="dialog__text">
{{ message }}
</p>
</slot>
</div>
</div>
<!-- The dialog actions aka the buttons -->
<div class="dialog__actions">
<slot name="actions" />
</div>
</div>
</NcModal>
</template>
<script>
import { computed, defineComponent, ref } from 'vue'
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
export default defineComponent({
name: 'NcDialog',
components: {
NcModal,
},
props: {
/** Name of the dialog (the heading) */
name: {
type: String,
required: true,
},
/** Text of the dialog */
message: {
type: String,
default: '',
},
/** Additional elements to add to the focus trap */
additionalTrapElements: {
type: Array,
validator: (arr) => Array.isArray(arr) && arr.every((element) => typeof element === 'string'),
default: () => ([]),
},
/**
* The element where to mount the dialog, if `null` is passed the dialog is mounted in place
* @default 'body'
*/
container: {
type: String,
required: false,
default: 'body',
},
/**
* Whether the dialog should be shown
* @default true
*/
open: {
type: Boolean,
default: true,
},
/**
* Size of the underlying NcModal
* @default 'small'
* @type {'small'|'normal'|'large'|'full'}
*/
size: {
type: String,
required: false,
default: 'small',
validator: (value) => typeof value === 'string' && ['small', 'normal', 'large', 'full'].includes(value),
},
/**
* Buttons to display
* @default []
*/
buttons: {
type: Array,
required: false,
default: () => ([]),
validator: (value) => Array.isArray(value) && value.every((element) => typeof element === 'object'),
},
/**
* Set to false to no show a close button on the dialog
* @default true
*/
canClose: {
type: Boolean,
default: true,
},
/**
* Close the dialog if the user clicked outside of the dialog
* Only relevant if `canClose` is set to true.
*/
closeOnClickOutside: {
type: Boolean,
default: false,
},
/**
* Declare if hiding the modal should be animated
* @default false
*/
outTransition: {
type: Boolean,
default: false,
},
/**
* Optionally pass additionaly classes which will be set on the navigation for custom styling
* @default ''
* @example
* ```html
* <DialogBase :navigation-classes="['mydialog-navigation']"><!-- --></DialogBase>
* <!-- ... -->
* <style lang="scss">
* :deep(.mydialog-navigation) {
* flex-direction: row-reverse;
* }
* </style>
* ```
*/
navigationClasses: {
type: [String, Array, Object],
required: false,
default: '',
},
/**
* Optionally pass additionaly classes which will be set on the content wrapper for custom styling
* @default ''
*/
contentClasses: {
type: [String, Array, Object],
required: false,
default: '',
},
/**
* Optionally pass additionaly classes which will be set on the dialog itself
* (the default `class` attribute will be set on the modal wrapper)
* @default ''
*/
dialogClasses: {
type: [String, Array, Object],
required: false,
default: '',
},
},
emits: ['closing', 'update:open'],
setup(props, { emit, slots }) {
/**
* We use the dialog width to decide if we collapse the navigation (flex direction row)
*/
const { width: dialogWidth } = { width: 900 }
/**
* Whether the navigation is collapsed due to dialog and window size
* (collapses when modal is below: 900px modal width - 2x 12px margin)
*/
const isNavigationCollapsed = computed(() => dialogWidth.value < 876)
/**
* Whether a navigation was passed and the element should be displayed
*/
const hasNavigation = computed(() => slots?.navigation !== undefined)
/**
* The unique id of the nav element
*/
const navigationId = ref(Math.random()
.toString(36)
.replace(/[^a-z]+/g, '')
.slice(0, 5),
)
/**
* If the underlaying modal is shown
*/
const showModal = ref(true)
// Because NcModal does not emit `close` when show prop is changed
/**
* Handle clicking a dialog button -> should close
*/
const handleButtonClose = () => {
handleClosing()
window.setTimeout(() => handleClosed(), 300)
}
/**
* Handle closing the dialog, optional out transition did not run yet
*/
const handleClosing = () => {
showModal.value = false
/**
* Emitted when the dialog is closing, so the out transition did not finish yet
*/
emit('closing')
}
/**
* Handle dialog closed (out transition finished)
*/
const handleClosed = () => {
showModal.value = true
/**
* Emitted then the dialog is fully closed and the out transition run
*/
emit('update:open', false)
}
/**
* Properties to pass to the underlying NcModal
*/
const modalProps = computed(() => ({
canClose: props.canClose,
container: props.container === undefined ? 'body' : props.container,
// we do not pass the name as we already have the name as the headline
// name: props.name,
size: props.size,
show: props.open && showModal.value,
outTransition: props.outTransition,
closeOnClickOutside: props.closeOnClickOutside,
enableSlideshow: false,
enableSwipe: false,
}))
return {
handleButtonClose,
handleClosing,
handleClosed,
hasNavigation,
navigationId,
isNavigationCollapsed,
modalProps,
}
},
})
</script>
<style lang="scss">
/** When having the small dialog style we override the modal styling so dialogs look more dialog like */
@media only screen and (max-width: 1024px) {
.dialog__modal .modal-wrapper--small .modal-container {
width: fit-content;
height: unset;
max-height: 90%;
position: relative;
top: unset;
border-radius: var(--border-radius-large);
}
}
</style>
<style lang="scss" scoped>
.dialog {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
padding-block: 4px 8px;
padding-inline: 12px 8px;
&__modal {
:deep(.modal-wrapper .modal-container) {
display: flex !important;
padding-block: 4px 8px; // 4px to align with close button, 8px block-end to allow the actions a margin of 4px for the focus visible outline
padding-inline: 12px 8px; // Same as with padding-block, we need the actions to have a margin of 4px for the button outline
}
:deep(.modal-container__content) {
display: flex;
flex-direction: column;
}
}
&__wrapper {
display: flex;
flex-direction: row;
// Auto scale to fit
flex: 1;
min-height: 0;
overflow: hidden;
// see modal-container padding, this aligns with the padding-inline-start (8px + 4px = 12px)
padding-inline-end: 4px;
&--collapsed {
flex-direction: column;
}
}
&__navigation {
display: flex;
flex-shrink: 0;
}
// Navigation styling when side-by-side with content
&__wrapper:not(&__wrapper--collapsed) &__navigation {
flex-direction: column;
overflow: hidden auto;
height: 100%;
min-width: 200px;
margin-inline-end: 20px;
}
// Navigation styling when on top of content
&__wrapper#{&}__wrapper--collapsed &__navigation {
flex-direction: row;
justify-content: space-between;
overflow: auto hidden;
width: 100%;
min-width: 100%;
}
&__name {
// Same as the NcAppSettingsDialog
text-align: center;
height: var(--default-clickable-area);
min-height: var(--default-clickable-area);
line-height: var(--default-clickable-area);
margin-block-end: 12px;
font-weight: bold;
font-size: 20px;
margin-bottom: 12px;
color: var(--color-text-light);
}
&__content {
// Auto fit
flex: 1;
min-height: 0;
overflow: auto;
}
// In case only text content is show
&__text {
// Also add padding to the bottom to make it more readable
padding-block-end: 6px;
}
&__actions {
display: flex;
gap: 6px;
align-content: center;
width: fit-content;
margin-inline: auto 4px; // 4px to align with the overall modal padding, we need this here for the buttons to have their 4px focus-visible outline
margin-block: 6px 4px; // 4px block-end see reason above
}
}
</style>

View file

@ -0,0 +1,73 @@
<template>
<VuePdfEditor ref="vuePdfEditor"
width="100%"
height="100%"
:show-choose-file-btn="false"
:show-customize-editor="false"
:show-line-size-select="false"
:show-font-size-select="false"
:show-font-select="false"
:show-save-btn="false"
:save-to-upload="false"
:init-file-src="fileSrc"
:init-image-scale="1"
:seal-image-show="false"
@pdf-editor:end-init="endInit">
<template #custom="{ object, pagesScale }">
<Signature :x="object.x"
:y="object.y"
:fix-size="false"
:display-name="object.signer.displayName"
:width="object.width"
:height="object.height"
:origin-width="object.originWidth"
:origin-height="object.originHeight"
:page-scale="pagesScale"
@onUpdate="$refs.vuePdfEditor.updateObject(object.id, $event)"
@onDelete="onDeleteSigner(object)" />
</template>
</VuePdfEditor>
</template>
<script>
import VuePdfEditor from '@libresign/vue-pdf-editor'
import Signature from './Signature.vue'
export default {
name: 'PdfEditor',
components: {
VuePdfEditor,
Signature,
},
props: {
fileSrc: {
type: String,
default: '',
require: true,
},
},
methods: {
endInit(event) {
this.$emit('pdf-editor:end-init', { ...event })
},
onDeleteSigner(object) {
this.$emit('pdf-editor:on-delete-signer', object)
this.$refs.vuePdfEditor.deleteObject(object.id)
},
addSigner(signer) {
const object = {
id: this.$refs.vuePdfEditor.genID(),
type: 'custom',
signer,
width: signer.element.coordinates.width,
height: signer.element.coordinates.height,
originWidth: signer.element.coordinates.width,
originHeight: signer.element.coordinates.height,
x: signer.element.coordinates.llx,
y: signer.element.coordinates.ury,
}
this.$refs.vuePdfEditor.addObject(object)
},
},
}
</script>

View file

@ -0,0 +1,244 @@
<template>
<div class="absolute left-0 top-0 select-none"
:style="{
width: `${width + dw}px`,
height: `${Math.round((width + dw) / ratio)}px`,
transform: `translate(${x + dx}px, ${y + dy}px)`,
}">
<div class="absolute w-full h-full cursor-grab"
:class="[
operation === 'move' ? 'cursor-grabbing' : '',
operation ? 'operation' : '',
]"
@mousedown="handlePanStart"
@touchstart="handlePanStart">
<div v-if="!fixSize"
data-direction="left-top"
class="absolute cursor-nwse-resize transform selector"
:style="{ top: '0%', left: '0%' }" />
<div v-if="!fixSize"
data-direction="right-top"
class="absolute cursor-nesw-resize transform selector"
:style="{ top: '0%', left: '100%' }" />
<div v-if="!fixSize"
data-direction="left-bottom"
class="absolute cursor-nesw-resize transform selector"
:style="{ top: '100%', left: '0%' }" />
<div v-if="!fixSize"
data-direction="right-bottom"
class="absolute cursor-nwse-resize transform selector"
:style="{ top: '100%', left: '100%' }" />
</div>
<div class="absolute cursor-pointer transform delete"
:style="{ top: '0%', left: '50%' }"
@click="onDelete">
<CloseCircleIcon class="w-full h-full"
text="Remove"
fill-color="red"
:size="25" />
</div>
<div class="w-full h-full border border-gray-400 border-dashed content">
{{ displayName }}
</div>
</div>
</template>
<script>
import itemEventsMixin from '@libresign/vue-pdf-editor/src/Components/ItemEventsMixin.vue'
import CloseCircleIcon from 'vue-material-design-icons/CloseCircle.vue'
export default {
name: 'Signature',
components: {
CloseCircleIcon,
},
mixins: [itemEventsMixin],
props: {
displayName: {
type: String,
default: '',
},
width: {
type: Number,
default: 0,
},
height: {
type: Number,
default: 0,
},
originWidth: {
type: Number,
default: 0,
},
originHeight: {
type: Number,
default: 0,
},
x: {
type: Number,
default: 0,
},
y: {
type: Number,
default: 0,
},
pageScale: {
type: Number,
default: 1,
},
fixSize: {
type: Boolean,
default: false,
},
},
data() {
return {
startX: null,
startY: null,
operation: '',
directions: [],
dx: 0,
dy: 0,
dw: 0,
dh: 0,
}
},
computed: {
ratio() {
return this.originWidth / this.originHeight
},
},
async mounted() {
await this.render()
},
methods: {
async render() {
let scale = 1
const MAX_TARGET = 500
if (this.width > MAX_TARGET) {
scale = MAX_TARGET / this.width
}
if (this.height > MAX_TARGET) {
scale = Math.min(scale, MAX_TARGET / this.height)
}
// eslint-disable-next-line vue/custom-event-name-casing
this.$emit('onUpdate', {
width: this.width * scale,
height: this.height * scale,
})
},
handlePanMove(event) {
let coordinate
if (event.type === 'mousemove') {
coordinate = this.handleMousemove(event)
}
if (event.type === 'touchmove') {
coordinate = this.handleTouchmove(event)
}
const _dx = (coordinate.detail.x - this.startX) / this.pageScale
const _dy = (coordinate.detail.y - this.startY) / this.pageScale
if (this.operation === 'move') {
this.dx = _dx
this.dy = _dy
} else if (this.operation === 'scale') {
if (this.directions.includes('left')) {
this.dx = _dx
this.dw = -_dx
}
if (this.directions.includes('top')) {
this.dy = _dy
this.dh = -_dy
}
if (this.directions.includes('right')) {
this.dw = _dx
}
if (this.directions.includes('bottom')) {
this.dh = _dy
}
}
},
handlePanEnd(event) {
if (event.type === 'mouseup') {
this.handleMouseup(event)
}
if (event.type === 'touchend') {
this.handleTouchend(event)
}
if (this.operation === 'move') {
// eslint-disable-next-line vue/custom-event-name-casing
this.$emit('onUpdate', {
x: this.x + this.dx,
y: this.y + this.dy,
})
this.dx = 0
this.dy = 0
} else if (this.operation === 'scale') {
// eslint-disable-next-line vue/custom-event-name-casing
this.$emit('onUpdate', {
x: this.x + this.dx,
y: this.y + this.dy,
width: this.width + this.dw,
height: Math.round((this.width + this.dw) / this.ratio),
})
this.dx = 0
this.dy = 0
this.dw = 0
this.dh = 0
this.directions = []
}
this.operation = ''
},
handlePanStart(event) {
let coordinate
if (event.type === 'mousedown') {
coordinate = this.handleMousedown(event)
}
if (event.type === 'touchstart') {
coordinate = this.handleTouchStart(event)
}
if (!coordinate) return
this.startX = coordinate.detail.x
this.startY = coordinate.detail.y
if (coordinate.detail.target === event.currentTarget) {
return (this.operation = 'move')
}
this.operation = 'scale'
this.directions = coordinate.detail.target.dataset.direction.split('-')
},
onDelete() {
// eslint-disable-next-line vue/custom-event-name-casing
this.$emit('onDelete')
},
},
}
</script>
<style scoped>
.operation {
background-color: rgba(0, 0, 0, 0.3);
}
.content {
color: var(--color-text-maxcontrast);
}
.selector {
border-radius: 10px;
width: 12px;
height: 12px;
margin-left: -6px;
margin-top: -6px;
background-color: #32b5fe;
border: 1px solid #32b5fe;
}
.delete {
border-radius: 10px;
width: 18px;
height: 18px;
margin-left: -9px;
margin-top: -9px;
background-color: #ffffff;
}
</style>

View file

@ -17,103 +17,78 @@
</small>
</p>
<Sidebar class="view-sign-detail--sidebar"
:signers="signers"
:signers="document.signers"
event="libresign:visible-elements-select-signer">
<button v-if="isDraft" class="primary publish-btn" @click="publish">
<NcButton v-if="canSave"
:type="canSave?'primary':'secondary'"
:wide="true"
@click="showConfirm = true">
{{ t('libresign', 'Request') }}
</button>
</NcButton>
<button v-if="canSign" class="primary publish-btn" @click="goToSign">
<NcButton v-if="canSign"
:type="!canSave?'primary':'secondary'"
:wide="true"
@click="goToSign">
{{ t('libresign', 'Sign') }}
</button>
</NcButton>
</Sidebar>
</div>
<div class="image-page">
<!-- <canvas ref="canvas" :width="page.resolution.w" :height="page.resolution.h" /> -->
<!-- <div :style="{ width: `${page.resolution.w}px`, height: `${page.resolution.h}px`, background: 'red' }">
<img :src="page.url">
</div> -->
<PageNavigation v-model="currentSigner.element.coordinates.page"
:pages="document.pages"
:width="pageDimensions.css.width" />
<div class="image-page--main">
<div class="image-page--container"
:style="{ '--page-img-w': pageDimensions.css.width, '--page-img-h': pageDimensions.css.height }">
<DragResize v-if="hasSignerSelected"
parent-limitation
:is-active="true"
:is-resizable="true"
:w="currentSigner.element.coordinates.width"
:h="currentSigner.element.coordinates.height"
:x="currentSigner.element.coordinates.left"
:y="currentSigner.element.coordinates.top"
@resizing="resize"
@dragging="resize">
<div class="image-page--element">
{{ currentSigner.displayName }}
</div>
<div class="image-page--action">
<button class="primary" @click="saveElement">
{{ t('libresign', editingElement ? 'Update' : 'Save') }}
</button>
</div>
</DragResize>
<img ref="img" :src="page.url">
</div>
</div>
<div v-if="loading"
class="image-page">
<NcLoadingIcon :size="64" name="Loading" />
<p>{{ t('libresign', 'Loading file') }}</p>
</div>
<div v-else class="image-page">
<PdfEditor ref="pdfEditor"
width="100%"
height="100%"
:file-src="document.file"
@pdf-editor:end-init="updateSigners"
@pdf-editor:on-delete-signer="onDeleteSigner" />
</div>
<NcDialog :open.sync="showConfirm"
:name="t('libresign', 'Confirm')"
:message="t('libresign', 'Request signatures?')">
<template #actions>
<NcButton type="secondary" @click="showConfirm = false">
{{ t('libresign', 'Cancel') }}
</NcButton>
<NcButton type="primary" @click="save">
{{ t('libresign', 'Request') }}
</NcButton>
</template>
</NcDialog>
</NcModal>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { showError } from '@nextcloud/dialogs'
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import DragResize from 'vue-drag-resize'
import { get, pick, find, map, cloneDeep, isEmpty } from 'lodash-es'
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
import NcDialog from '../Nextcloud/NcDialog/NcDialog.vue'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { service as signService, SIGN_STATUS } from '../../domains/sign/index.js'
import { SIGN_STATUS } from '../../domains/sign/enum.js'
import Sidebar from './SignDetail/partials/Sidebar.vue'
import PageNavigation from './SignDetail/partials/PageNavigation.vue'
import { showResponseError } from '../../helpers/errors.js'
import { SignatureImageDimensions } from '../Draw/index.js'
import Chip from '../Chip.vue'
const emptyElement = () => {
return {
coordinates: {
page: 1,
left: 100,
top: 100,
height: SignatureImageDimensions.height,
width: SignatureImageDimensions.width,
},
elementId: 0,
}
}
const emptySignerData = () => ({
signed: null,
displayName: '',
fullName: null,
me: false,
signRequestId: 0,
email: '',
element: emptyElement(),
})
const deepCopy = val => JSON.parse(JSON.stringify(val))
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import PdfEditor from '../PdfEditor/PdfEditor.vue'
export default {
name: 'VisibleElements',
components: {
NcModal,
DragResize,
NcDialog,
Sidebar,
PageNavigation,
Chip,
NcButton,
NcLoadingIcon,
PdfEditor,
},
props: {
file: {
@ -125,67 +100,47 @@ export default {
data() {
return {
canRequestSign: loadState('libresign', 'can_request_sign'),
signers: [],
document: {
id: '',
name: '',
signers: [],
pages: [],
visibleElements: [],
loading: false,
},
modal: false,
currentSigner: emptySignerData(),
showConfirm: false,
}
},
computed: {
pageIndex() {
return this.currentSigner.element.coordinates.page - 1
},
canSign() {
if (this.status !== SIGN_STATUS.ABLE_TO_SIGN) {
return false
}
return !isEmpty(this.signerFileUuid)
return (this.document?.settings?.signerFileUuid ?? '').length > 0
},
canSave() {
if (
[
SIGN_STATUS.DRAFT,
SIGN_STATUS.ABLE_TO_SIGN,
SIGN_STATUS.PARTIAL_SIGNED,
].includes(this.status)
) {
return true
}
return false
},
status() {
return Number(get(this.document, 'status', -1))
return Number(this.document?.status ?? -1)
},
statusLabel() {
return get(this.document, 'statusText', '')
return this.document.statusText
},
isDraft() {
return this.status === SIGN_STATUS.DRAFT
},
page() {
return this.document.pages[this.pageIndex] || {
url: '',
resolution: {
h: 0,
w: 0,
},
}
},
pageDimensions() {
const { w, h } = this.page.resolution
return {
height: h,
width: w,
css: {
height: `${Math.ceil(h)}px`,
width: `${Math.ceil(w)}px`,
},
}
},
hasSignerSelected() {
return this.currentSigner.signRequestId !== 0
},
editingElement() {
return this.currentSigner.element.elementId > 0
},
signerFileUuid() {
return get(this.document, ['settings', 'signerFileUuid'])
},
},
mounted() {
subscribe('libresign:show-visible-elements', this.showModal)
@ -214,109 +169,86 @@ export default {
return showError(err.message)
},
updateSigners() {
const { signRequestId } = this.currentSigner
this.currentSigner = emptySignerData()
const [signers, visibleElements] = deepCopy([this.document.signers, this.document.visibleElements])
this.signers = map(signers, signer => {
const element = find(visibleElements, (el) => {
return el.signRequestId === signer.signRequestId
})
const row = {
...signer,
element: emptyElement(),
this.document.signers.forEach(signer => {
if (this.document.visibleElements) {
this.document.visibleElements.forEach(element => {
if (element.signRequestId === signer.signRequestId) {
const object = structuredClone(signer)
object.element = element
this.$refs.pdfEditor.addSigner(object)
}
})
}
if (element) {
const coordinates = pick(element.coordinates, ['top', 'left', 'width', 'height', 'page'])
row.element = {
elementId: element.elementId,
coordinates,
}
}
return row
})
if (signRequestId === 0) {
return
}
const current = this.signers.find(signer => signer.signRequestId === signRequestId)
this.onSelectSigner({ ...current })
},
resize(newRect) {
const { coordinates } = this.currentSigner.element
this.currentSigner.element.coordinates = {
...coordinates,
...newRect,
}
},
onSelectSigner(signer) {
const page = this.pageIndex + 1
this.currentSigner = emptySignerData()
this.currentSigner = cloneDeep(signer)
if (signer.element.elementId === 0) {
this.currentSigner.element.coordinates.page = page
signer.element = {
coordinates: {
page: 1,
llx: 100,
ury: 100,
height: SignatureImageDimensions.height,
width: SignatureImageDimensions.width,
},
}
this.$refs.pdfEditor.addSigner(signer)
},
async onDeleteSigner(object) {
if (!object.signer.element.elementId) {
return
}
await axios.delete(generateOcsUrl('/apps/libresign/api/v1/file-element/{uuid}/{elementId}', {
uuid: this.document.uuid,
elementId: object.signer.element.elementId,
}))
},
goToSign() {
const route = this.$router.resolve({ name: 'SignPDF', params: { uuid: this.signerFileUuid } })
window.location.href = route.href
},
async publish() {
const allow = confirm(t('libresign', 'Request signatures?'))
if (!allow) {
return
}
async save() {
try {
await signService.changeRegisterStatus(this.document.fileId, SIGN_STATUS.ABLE_TO_SIGN)
this.loadDocument()
const visibleElements = []
Object.entries(this.$refs.pdfEditor.$refs.vuePdfEditor.allObjects).forEach(entry => {
const [pageNumber, page] = entry
page.forEach(function(element) {
visibleElements.push({
type: 'signature',
signRequestId: element.signer.signRequestId,
elementId: element.signer.element.elementId,
coordinates: {
page: parseInt(pageNumber) + 1,
width: element.width,
height: element.height,
llx: element.x,
lly: element.y + element.height,
ury: element.y,
urx: element.x + element.width,
},
})
})
})
await axios.patch(generateOcsUrl('/apps/libresign/api/v1/request-signature'), {
users: [],
uuid: this.file.uuid,
visibleElements,
status: 0,
})
this.showConfirm = false
this.closeModal()
} catch (err) {
this.onError(err)
}
},
async loadDocument() {
try {
this.signers = []
this.document = await axios.get(generateOcsUrl(`/apps/libresign/api/v1/file/validate/file_id/${this.file.nodeId}`))
this.document = this.document.data
this.updateSigners()
} catch (err) {
this.onError(err)
}
},
async saveElement() {
const { element, signRequestId } = this.currentSigner
const payload = {
coordinates: {
...element.coordinates,
page: element.coordinates.page,
},
type: 'signature',
signRequestId,
}
try {
this.editingElement
? await axios.patch(generateOcsUrl(`/apps/libresign/api/v1/file-element/${this.document.uuid}/${element.elementId}`), payload)
: await axios.post(generateOcsUrl(`/apps/libresign/api/v1/file-element/${this.document.uuid}`), payload)
showSuccess(t('libresign', 'Element created'))
this.loadDocument()
this.loading = true
const document = await axios.get(generateOcsUrl(`/apps/libresign/api/v1/file/validate/file_id/${this.file.nodeId}`))
this.document = document.data
this.loading = false
} catch (err) {
this.loading = false
this.onError(err)
}
},
@ -324,9 +256,17 @@ export default {
}
</script>
<style lang="scss">
.image-page {
.py-12,.p-5 {
all: unset;
}
}
</style>
<style lang="scss" scoped>
.sign-details {
margin-left: 5px;
padding: 8px;
}
.view-sign-detail {
@ -334,54 +274,8 @@ export default {
width: 300px;
}
overflow: auto;
}
.image-page {
width: 100%;
margin: 0.5em;
&--main {
position: relative;
.button-vue {
margin: 4px;
}
&--element {
width: 100%;
height: 100%;
display: flex;
position: absolute;
cursor: grab;
background: rgba(0, 0, 0, 0.3);
color: #FFF;
font-weight: bold;
justify-content: space-around;
align-items: center;
flex-direction: row;
&:active {
cursor: grabbing;
}
}
&--action {
width: 100%;
position: absolute;
top: 100%;
}
&--container {
border-color: #000;
border-style: solid;
border-width: thin;
width: var(--page-img-w);
height: var(--page-img-h);
left: 0;
top: 0;
&, img {
user-select: none;
outline: 0;
}
img {
max-width: 100%;
}
}
}
.publish-btn {
width: 100%;
}
</style>

View file

@ -11,17 +11,6 @@ import {
*/
const buildService = (http) => {
return ({
/**
* @param {string} uuid uuid
*
* @return {*}
*/
async validateByUUID(uuid) {
const { data } = await http.get(generateOcsUrl(`/apps/libresign/api/v1/file/validate/uuid/${uuid}`))
return data
},
async signDocument({ fileId, password, elements, code }) {
const url = String(fileId).length >= 10
? generateOcsUrl(`/apps/libresign/api/v1/sign/uuid/${fileId}`)
@ -37,68 +26,6 @@ const buildService = (http) => {
return data
},
/**
* @param {string} fileID fileID
* @param {string} email email
*
* @return {*}
*/
async notifySigner(fileID, email) {
const body = {
fileId: fileID,
signers: [
{
email,
},
],
}
const { data } = await http.post(generateOcsUrl('/apps/libresign/api/v1/notify/signers'), body)
return data
},
/**
* @param {string} fileID fileID
* @param {string} signerId signerId
*
* @return {*}
*/
async removeSigner(fileID, signerId) {
const { data } = await http.delete(generateOcsUrl(`/apps/libresign/api/v1/sign/file_id/${fileID}/${signerId}`))
return data
},
/**
* update sign document register
*
* @param {string} fileId fileId
* @param {Record<string, unknown>} content content
*
* @return {Promise<unknown>}
*/
async updateRegister(fileId, content = {}) {
const url = generateOcsUrl('/apps/libresign/api/v1/request-signature')
const body = {
file: { fileId },
...content,
}
const { data } = await http.patch(url, body)
return data
},
/**
* change document sign status
*
* @param {string} fileId fileId
* @param {number} status new status
*
* @return {Promise<unknown>}
*/
changeRegisterStatus(fileId, status) {
return this.updateRegister(fileId, { status })
},
/**
* request sign code
*

View file

@ -1,34 +1,44 @@
const { merge } = require('webpack-merge')
const path = require('path')
const webpackConfig = require('@nextcloud/webpack-vue-config')
const nextcloudWebpackConfig = require('@nextcloud/webpack-vue-config')
const config = {
entry: {
tab: path.resolve(path.join('src', 'tab.js')),
settings: path.resolve(path.join('src', 'settings.js')),
external: path.resolve(path.join('src', 'external.js')),
validation: path.resolve(path.join('src', 'validation.js')),
},
optimization: process.env.NODE_ENV === 'production'
? { chunkIds: 'deterministic' }
: {},
module: {
rules: [
{
test: /\.(ttf|otf|eot|woff|woff2)$/,
use: {
loader: 'file-loader',
options: {
name: 'fonts/[name].[ext]',
},
},
},
{
resourceQuery: /raw/,
type: 'asset/source',
},
],
}
}
module.exports = merge(webpackConfig, config)
module.exports = merge(nextcloudWebpackConfig, {
entry: {
tab: path.resolve(path.join('src', 'tab.js')),
settings: path.resolve(path.join('src', 'settings.js')),
external: path.resolve(path.join('src', 'external.js')),
validation: path.resolve(path.join('src', 'validation.js')),
},
optimization: process.env.NODE_ENV === 'production'
? { chunkIds: 'deterministic' }
: {},
devServer: {
port: 3000, // use any port suitable for your configuration
host: '0.0.0.0', // to accept connections from outside container
},
output: {
assetModuleFilename: '[name][ext]?v=[contenthash]',
},
module: {
rules: [
{
test: /\.(ttf|otf|eot|woff|woff2)$/,
use: {
loader: 'file-loader',
options: {
name: 'fonts/[name].[ext]',
},
},
},
// Load raw SVGs to be able to inject them via v-html
{
test: /@mdi\/svg/,
type: 'asset/source',
},
{
resourceQuery: /raw/,
type: 'asset/source',
},
],
}
})