Merge branch 'signature-app/add-manual-zone' into 'signature-app-master'

Improve signature app

See merge request Chill-Projet/chill-bundles!725
This commit is contained in:
Julien Fastré 2024-09-13 14:23:44 +00:00
commit 1494c7ecd7
16 changed files with 538 additions and 236 deletions

View File

@ -15,12 +15,17 @@ use Chill\DocStoreBundle\Service\Signature\Driver\BaseSigner\RequestPdfSignMessa
use Chill\DocStoreBundle\Service\Signature\PDFPage; use Chill\DocStoreBundle\Service\Signature\PDFPage;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone; use Chill\DocStoreBundle\Service\Signature\PDFSignatureZone;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class SignatureRequestController class SignatureRequestController
{ {
@ -28,12 +33,20 @@ class SignatureRequestController
private readonly MessageBusInterface $messageBus, private readonly MessageBusInterface $messageBus,
private readonly StoredObjectManagerInterface $storedObjectManager, private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly EntityWorkflowManager $entityWorkflowManager, private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly ChillEntityRenderManagerInterface $entityRender,
private readonly NormalizerInterface $normalizer,
private readonly Security $security,
) {} ) {}
#[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')] #[Route('/api/1.0/document/workflow/{id}/signature-request', name: 'chill_docstore_signature_request')]
public function processSignature(EntityWorkflowStepSignature $signature, Request $request): JsonResponse public function processSignature(EntityWorkflowStepSignature $signature, Request $request): JsonResponse
{ {
$entityWorkflow = $signature->getStep()->getEntityWorkflow(); $entityWorkflow = $signature->getStep()->getEntityWorkflow();
if (EntityWorkflowSignatureStateEnum::PENDING !== $signature->getState()) {
return new JsonResponse([], status: Response::HTTP_CONFLICT);
}
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
$content = $this->storedObjectManager->read($storedObject); $content = $this->storedObjectManager->read($storedObject);
@ -51,8 +64,14 @@ class SignatureRequestController
$signature->getId(), $signature->getId(),
$zone, $zone,
$data['zone']['index'], $data['zone']['index'],
'test signature', // reason (string) 'Signed by IP: '.(string) $request->getClientIp().', authenticated user: '.$this->entityRender->renderString($this->security->getUser(), []),
'Mme Caroline Diallo', // signerText (string) $this->entityRender->renderString($signature->getSigner(), [
// options for user render
'absence' => false,
'main_scope' => false,
// options for person render
'addAge' => false,
]),
$content $content
)); ));
@ -62,6 +81,16 @@ class SignatureRequestController
#[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')] #[Route('/api/1.0/document/workflow/{id}/check-signature', name: 'chill_docstore_check_signature')]
public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse public function checkSignature(EntityWorkflowStepSignature $signature): JsonResponse
{ {
return new JsonResponse($signature->getState(), JsonResponse::HTTP_OK, []); $entityWorkflow = $signature->getStep()->getEntityWorkflow();
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
return new JsonResponse(
[
'state' => $signature->getState(),
'storedObject' => $this->normalizer->normalize($storedObject, 'json'),
],
JsonResponse::HTTP_OK,
[]
);
} }
} }

View File

@ -1,100 +1,119 @@
import {DateTime, User} from "../../../ChillMainBundle/Resources/public/types"; import {
DateTime,
User,
} from "../../../ChillMainBundle/Resources/public/types";
export type StoredObjectStatus = "empty"|"ready"|"failure"|"pending"; export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending";
export interface StoredObject { export interface StoredObject {
id: number, id: number;
title: string|null, title: string | null;
uuid: string, uuid: string;
prefix: string, prefix: string;
status: StoredObjectStatus, status: StoredObjectStatus;
currentVersion: null|StoredObjectVersionCreated|StoredObjectVersionPersisted, currentVersion:
totalVersions: number, | null
datas: object, | StoredObjectVersionCreated
| StoredObjectVersionPersisted;
totalVersions: number;
datas: object;
/** @deprecated */ /** @deprecated */
creationDate: DateTime, creationDate: DateTime;
createdAt: DateTime|null, createdAt: DateTime | null;
createdBy: User|null, createdBy: User | null;
_permissions: { _permissions: {
canEdit: boolean, canEdit: boolean;
canSee: boolean, canSee: boolean;
}, };
_links?: { _links?: {
dav_link?: { dav_link?: {
href: string href: string;
expiration: number expiration: number;
}, };
}, };
} }
export interface StoredObjectVersion { export interface StoredObjectVersion {
/** /**
* filename of the object in the object storage * filename of the object in the object storage
*/ */
filename: string, filename: string;
iv: number[], iv: number[];
keyInfos: JsonWebKey, keyInfos: JsonWebKey;
type: string, type: string;
} }
export interface StoredObjectVersionCreated extends StoredObjectVersion { export interface StoredObjectVersionCreated extends StoredObjectVersion {
persisted: false, persisted: false;
} }
export interface StoredObjectVersionPersisted extends StoredObjectVersionCreated { export interface StoredObjectVersionPersisted
version: number, extends StoredObjectVersionCreated {
id: number, version: number;
createdAt: DateTime|null, id: number;
createdBy: User|null, createdAt: DateTime | null;
createdBy: User | null;
} }
export interface StoredObjectStatusChange { export interface StoredObjectStatusChange {
id: number, id: number;
filename: string, filename: string;
status: StoredObjectStatus, status: StoredObjectStatus;
type: string, type: string;
} }
/** /**
* Function executed by the WopiEditButton component. * Function executed by the WopiEditButton component.
*/ */
export type WopiEditButtonExecutableBeforeLeaveFunction = { export type WopiEditButtonExecutableBeforeLeaveFunction = {
(): Promise<void> (): Promise<void>;
} };
/** /**
* Object containing information for performering a POST request to a swift object store * Object containing information for performering a POST request to a swift object store
*/ */
export interface PostStoreObjectSignature { export interface PostStoreObjectSignature {
method: "POST", method: "POST";
max_file_size: number, max_file_size: number;
max_file_count: 1, max_file_count: 1;
expires: number, expires: number;
submit_delay: 180, submit_delay: 180;
redirect: string, redirect: string;
prefix: string, prefix: string;
url: string, url: string;
signature: string, signature: string;
} }
export interface PDFPage { export interface PDFPage {
index: number, index: number;
width: number, width: number;
height: number, height: number;
} }
export interface SignatureZone { export interface SignatureZone {
index: number, index: number | null;
x: number, x: number;
y: number, y: number;
width: number, width: number;
height: number, height: number;
PDFPage: PDFPage, PDFPage: PDFPage;
} }
export interface Signature { export interface Signature {
id: number, id: number;
storedObject: StoredObject, storedObject: StoredObject;
zones: SignatureZone[], zones: SignatureZone[];
} }
export type SignedState = 'pending' | 'signed' | 'rejected' | 'canceled' | 'error'; export type SignedState =
| "pending"
| "signed"
| "rejected"
| "canceled"
| "error";
export interface CheckSignature {
state: SignedState;
storedObject: StoredObject;
}
export type CanvasEvent = "select" | "add";

View File

@ -26,37 +26,9 @@
</template> </template>
</modal> </modal>
</teleport> </teleport>
<div class="col-12"> <div class="col-12 m-auto">
<div <div class="row justify-content-center border-bottom pdf-tools d-md-none">
class="row justify-content-center mb-2" <div v-if="pageCount > 1" class="col text-center turn-page">
v-if="signature.zones.length > 1"
>
<div class="col-4 gap-2 d-grid">
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_sign_zone") }}
</button>
</div>
<div class="col-4 gap-2 d-grid">
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_sign_zone") }}
</button>
</div>
</div>
<div
id="turn-page"
class="row justify-content-center mb-2"
v-if="pageCount > 1"
>
<div class="col-6-sm col-3-md text-center">
<button <button
class="btn btn-light btn-sm" class="btn btn-light btn-sm"
:disabled="page <= 1" :disabled="page <= 1"
@ -64,7 +36,7 @@
> >
</button> </button>
<span>page {{ page }} / {{ pageCount }}</span> <span>{{ page }}/{{ pageCount }}</span>
<button <button
class="btn btn-light btn-sm" class="btn btn-light btn-sm"
:disabled="page >= pageCount" :disabled="page >= pageCount"
@ -73,15 +45,161 @@
</button> </button>
</div> </div>
<div v-if="signature.zones.length > 1" class="col-3 p-0">
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_zone") }}
</button>
</div>
<div v-if="signature.zones.length > 1" class="col-3 p-0">
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_zone") }}
</button>
</div>
<div class="col text-end p-0">
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-if="signature.zones.length > 1"
:title="$t('choose_another_signature')"
>
{{ $t("another_zone") }}
</button>
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
>
{{ $t("cancel") }}
</button>
</div>
<div class="col-1" v-if="signedState !== 'signed'">
<button
class="btn btn-create btn-sm"
:class="{ active: canvasEvent === 'add' }"
@click="toggleAddZone()"
:title="$t('add_sign_zone')"
></button>
</div>
</div>
<div
class="row justify-content-center border-bottom pdf-tools d-none d-md-flex"
>
<div v-if="pageCount > 1" class="col-2 text-center turn-page p-0">
<button
class="btn btn-light btn-sm"
:disabled="page <= 1"
@click="turnPage(-1)"
>
</button>
<span>{{ page }} / {{ pageCount }}</span>
<button
class="btn btn-light btn-sm"
:disabled="page >= pageCount"
@click="turnPage(1)"
>
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-end d-xl-none"
>
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_zone") }}
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-start d-xl-none"
>
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_zone") }}
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-end d-none d-xl-flex p-0"
>
<button
:disabled="userSignatureZone === null || userSignatureZone?.index < 1"
class="btn btn-light btn-sm"
@click="turnSignature(-1)"
>
{{ $t("last_sign_zone") }}
</button>
</div>
<div
v-if="signature.zones.length > 1 && signedState !== 'signed'"
class="col text-start d-none d-xl-flex p-0"
>
<button
:disabled="userSignatureZone?.index >= signature.zones.length - 1"
class="btn btn-light btn-sm"
@click="turnSignature(1)"
>
{{ $t("next_sign_zone") }}
</button>
</div>
<div class="col text-end p-0" v-if="signedState !== 'signed'">
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-if="signature.zones.length > 1"
>
{{ $t("choose_another_signature") }}
</button>
<button
class="btn btn-misc btn-sm"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
>
{{ $t("cancel") }}
</button>
</div>
<div
class="col text-end p-0 pe-2 pe-xxl-4"
v-if="signedState !== 'signed'"
>
<button
class="btn btn-create btn-sm"
:class="{ active: canvasEvent === 'add' }"
@click="toggleAddZone()"
:title="$t('add_sign_zone')"
>
{{ $t("add_zone") }}
</button>
</div>
</div> </div>
</div> </div>
<div class="col-12 text-center"> <div class="col-xs-12 col-md-12 col-lg-9 m-auto my-5 text-center">
<canvas class="m-auto" id="canvas"></canvas> <canvas class="m-auto" id="canvas"></canvas>
</div> </div>
<div class="col-12 p-4" id="action-buttons" v-if="signedState !== 'signed'"> <div class="col-xs-12 col-md-12 col-lg-9 m-auto p-4" id="action-buttons">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-4" v-if="signedState !== 'signed'">
<button <button
class="btn btn-action me-2" class="btn btn-action me-2"
:disabled="!userSignatureZone" :disabled="!userSignatureZone"
@ -90,26 +208,18 @@
{{ $t("sign") }} {{ $t("sign") }}
</button> </button>
</div> </div>
<div class="col-6 d-flex justify-content-end"> <div class="col-4" v-else></div>
<button <div class="col-8 d-flex justify-content-end">
class="btn btn-misc me-2" <a
:hidden="!userSignatureZone" class="btn btn-delete"
@click="undoSign" v-if="signedState !== 'signed'"
v-if="signature.zones.length > 1" :href="getReturnPath()"
> >
{{ $t("choose_another_signature") }}
</button>
<button
class="btn btn-misc me-2"
:hidden="!userSignatureZone"
@click="undoSign"
v-else
>
{{ $t("cancel") }}
</button>
<button class="btn btn-delete" @click="undoSign">
{{ $t("cancel_signing") }} {{ $t("cancel_signing") }}
</button> </a>
<a class="btn btn-misc" v-else :href="getReturnPath()">
{{ $t("return") }}
</a>
</div> </div>
</div> </div>
</div> </div>
@ -119,7 +229,13 @@
import { ref, Ref, reactive } from "vue"; import { ref, Ref, reactive } from "vue";
import { useToast } from "vue-toast-notification"; import { useToast } from "vue-toast-notification";
import "vue-toast-notification/dist/theme-sugar.css"; import "vue-toast-notification/dist/theme-sugar.css";
import { Signature, SignatureZone, SignedState } from "../../types"; import {
CanvasEvent,
CheckSignature,
Signature,
SignatureZone,
SignedState,
} from "../../types";
import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import * as pdfjsLib from "pdfjs-dist"; import * as pdfjsLib from "pdfjs-dist";
import { import {
@ -135,19 +251,18 @@ console.log(PdfWorker); // incredible but this is needed
// pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker; // pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker;
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { import { download_and_decrypt_doc } from "../StoredObjectButton/helpers";
download_and_decrypt_doc,
} from "../StoredObjectButton/helpers";
pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs"; pdfjsLib.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs";
const modalOpen: Ref<boolean> = ref(false); const modalOpen: Ref<boolean> = ref(false);
const loading: Ref<boolean> = ref(false); const loading: Ref<boolean> = ref(false);
const adding: Ref<boolean> = ref(false);
const canvasEvent: Ref<CanvasEvent> = ref("select");
const signedState: Ref<SignedState> = ref("pending"); const signedState: Ref<SignedState> = ref("pending");
const page: Ref<number> = ref(1); const page: Ref<number> = ref(1);
const pageCount: Ref<number> = ref(0); const pageCount: Ref<number> = ref(0);
let userSignatureZone: Ref<null | SignatureZone> = ref(null); let userSignatureZone: Ref<null | SignatureZone> = ref(null);
let pdfSource: Ref<string> = ref("");
let pdf = {} as PDFDocumentProxy; let pdf = {} as PDFDocumentProxy;
declare global { declare global {
@ -160,11 +275,13 @@ const $toast = useToast();
const signature = window.signature; const signature = window.signature;
console.log(signature);
const mountPdf = async (url: string) => { const mountPdf = async (url: string) => {
const loadingTask = pdfjsLib.getDocument(url); const loadingTask = pdfjsLib.getDocument(url);
pdf = await loadingTask.promise; pdf = await loadingTask.promise;
pageCount.value = pdf.numPages; pageCount.value = pdf.numPages;
await setPage(1); await setPage(page.value);
}; };
const getRenderContext = (pdfPage: PDFPageProxy) => { const getRenderContext = (pdfPage: PDFPageProxy) => {
@ -187,59 +304,61 @@ const setPage = async (page: number) => {
await pdfPage.render(renderContext); await pdfPage.render(renderContext);
}; };
const init = () => downloadAndOpen().then(initPdf);
async function downloadAndOpen(): Promise<Blob> { async function downloadAndOpen(): Promise<Blob> {
let raw; let raw;
try { try {
raw = await download_and_decrypt_doc(signature.storedObject, signature.storedObject.currentVersion); raw = await download_and_decrypt_doc(
signature.storedObject,
signature.storedObject.currentVersion
);
} catch (e) { } catch (e) {
console.error("error while downloading and decrypting document", e); console.error("error while downloading and decrypting document", e);
throw e; throw e;
} }
await mountPdf(URL.createObjectURL(raw)); await mountPdf(URL.createObjectURL(raw));
initPdf();
return raw; return raw;
} }
const initPdf = () => { const initPdf = () => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement; const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvas.addEventListener( canvas.addEventListener("pointerup", canvasClick, false);
"pointerup", setTimeout(() => drawAllZones(page.value), 800);
(e: PointerEvent) => canvasClick(e, canvas),
false
);
setTimeout(() => addZones(page.value), 800);
}; };
const scaleXToCanvas = (x: number, canvasWidth: number, PDFWidth: number) =>
Math.round((x * canvasWidth) / PDFWidth);
const scaleYToCanvas = (h: number, canvasHeight: number, PDFHeight: number) =>
Math.round((h * canvasHeight) / PDFHeight);
const hitSignature = ( const hitSignature = (
zone: SignatureZone, zone: SignatureZone,
xy: number[], xy: number[],
canvasWidth: number, canvasWidth: number,
canvasHeight: number canvasHeight: number
) => { ) =>
const scaleXToCanvas = (x: number) => scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) < xy[0] &&
Math.round((x * canvasWidth) / zone.PDFPage.width); xy[0] <
const scaleHeightToCanvas = (h: number) => scaleXToCanvas(zone.x + zone.width, canvasWidth, zone.PDFPage.width) &&
Math.round((h * canvasHeight) / zone.PDFPage.height); zone.PDFPage.height -
const scaleYToCanvas = (y: number) => scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) <
Math.round(zone.PDFPage.height - scaleHeightToCanvas(y)); xy[1] &&
return ( xy[1] <
scaleXToCanvas(zone.x) < xy[0] && scaleYToCanvas(zone.height - zone.y, canvasHeight, zone.PDFPage.height) +
xy[0] < scaleXToCanvas(zone.x + zone.width) && zone.PDFPage.height;
scaleYToCanvas(zone.y) < xy[1] &&
xy[1] < scaleYToCanvas(zone.y) + scaleHeightToCanvas(zone.height)
);
};
const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => { const selectZone = (z: SignatureZone, canvas: HTMLCanvasElement) => {
userSignatureZone.value = z; userSignatureZone.value = z;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (ctx) { if (ctx) {
setPage(page.value); setPage(page.value);
setTimeout(() => drawZone(z, ctx, canvas.width, canvas.height), 200); setTimeout(() => drawAllZones(page.value), 200);
} }
}; };
const canvasClick = (e: PointerEvent, canvas: HTMLCanvasElement) => const selectZoneEvent = (e: PointerEvent, canvas: HTMLCanvasElement) =>
signature.zones signature.zones
.filter((z) => z.PDFPage.index + 1 === page.value) .filter((z) => z.PDFPage.index + 1 === page.value)
.map((z) => { .map((z) => {
@ -256,11 +375,18 @@ const canvasClick = (e: PointerEvent, canvas: HTMLCanvasElement) =>
} }
}); });
const canvasClick = (e: PointerEvent) => {
const canvas = document.querySelectorAll("canvas")[0] as HTMLCanvasElement;
canvasEvent.value === "select"
? selectZoneEvent(e, canvas)
: addZoneEvent(e, canvas);
};
const turnPage = async (upOrDown: number) => { const turnPage = async (upOrDown: number) => {
userSignatureZone.value = null; //userSignatureZone.value = null; // desactivate the reset of the zone when turning page
page.value = page.value + upOrDown; page.value = page.value + upOrDown;
await setPage(page.value); await setPage(page.value);
setTimeout(() => addZones(page.value), 200); setTimeout(() => drawAllZones(page.value), 200);
}; };
const turnSignature = async (upOrDown: number) => { const turnSignature = async (upOrDown: number) => {
@ -290,12 +416,6 @@ const drawZone = (
) => { ) => {
const unselectedBlue = "#007bff"; const unselectedBlue = "#007bff";
const selectedBlue = "#034286"; const selectedBlue = "#034286";
const scaleXToCanvas = (x: number) =>
Math.round((x * canvasWidth) / zone.PDFPage.width);
const scaleHeightToCanvas = (h: number) =>
Math.round((h * canvasHeight) / zone.PDFPage.height);
const scaleYToCanvas = (y: number) =>
Math.round(zone.PDFPage.height - scaleHeightToCanvas(y));
ctx.strokeStyle = ctx.strokeStyle =
userSignatureZone.value?.index === zone.index userSignatureZone.value?.index === zone.index
? selectedBlue ? selectedBlue
@ -303,16 +423,22 @@ const drawZone = (
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.lineJoin = "bevel"; ctx.lineJoin = "bevel";
ctx.strokeRect( ctx.strokeRect(
scaleXToCanvas(zone.x), scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width),
scaleYToCanvas(zone.y), zone.PDFPage.height -
scaleXToCanvas(zone.width), scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height),
scaleHeightToCanvas(zone.height) scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width),
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height)
); );
ctx.font = "bold 16px serif"; ctx.font = "bold 16px serif";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.fillStyle = "black"; ctx.fillStyle = "black";
const xText = scaleXToCanvas(zone.x) + scaleXToCanvas(zone.width) / 2; const xText =
const yText = scaleYToCanvas(zone.y) + scaleHeightToCanvas(zone.height) / 2; scaleXToCanvas(zone.x, canvasWidth, zone.PDFPage.width) +
scaleXToCanvas(zone.width, canvasWidth, zone.PDFPage.width) / 2;
const yText =
zone.PDFPage.height -
scaleYToCanvas(zone.y, canvasHeight, zone.PDFPage.height) +
scaleYToCanvas(zone.height, canvasHeight, zone.PDFPage.height) / 2;
if (userSignatureZone.value?.index === zone.index) { if (userSignatureZone.value?.index === zone.index) {
ctx.fillStyle = selectedBlue; ctx.fillStyle = selectedBlue;
ctx.fillText("Signer ici", xText, yText); ctx.fillText("Signer ici", xText, yText);
@ -320,27 +446,33 @@ const drawZone = (
ctx.fillStyle = unselectedBlue; ctx.fillStyle = unselectedBlue;
ctx.fillText("Choisir cette", xText, yText - 12); ctx.fillText("Choisir cette", xText, yText - 12);
ctx.fillText("zone de signature", xText, yText + 12); ctx.fillText("zone de signature", xText, yText + 12);
// ctx.strokeStyle = "#c6c6c6"; // halo
// ctx.strokeText("Choisir cette", xText, yText - 12);
// ctx.strokeText("zone de signature", xText, yText + 12);
} }
}; };
const addZones = (page: number) => { const drawAllZones = (page: number) => {
const canvas = document.querySelectorAll("canvas")[0]; const canvas = document.querySelectorAll("canvas")[0];
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (ctx) { if (ctx && signedState.value !== "signed") {
signature.zones signature.zones
.filter((z) => z.PDFPage.index + 1 === page) .filter((z) => z.PDFPage.index + 1 === page)
.map((z) => drawZone(z, ctx, canvas.width, canvas.height)); .map((z) => {
if (userSignatureZone.value) {
if (userSignatureZone.value?.index === z.index) {
drawZone(z, ctx, canvas.width, canvas.height);
}
} else {
drawZone(z, ctx, canvas.width, canvas.height);
}
});
} }
}; };
const checkSignature = () => { const checkSignature = () => {
const url = `/api/1.0/document/workflow/${signature.id}/check-signature`; const url = `/api/1.0/document/workflow/${signature.id}/check-signature`;
return makeFetch("GET", url) return makeFetch<null, CheckSignature>("GET", url)
.then((r) => { .then((r) => {
signedState.value = r as SignedState; signedState.value = r.state;
signature.storedObject = r.storedObject;
checkForReady(); checkForReady();
}) })
.catch((error) => { .catch((error) => {
@ -414,22 +546,66 @@ const confirmSign = () => {
}; };
const undoSign = async () => { const undoSign = async () => {
// const canvas = document.querySelectorAll("canvas")[0]; signature.zones = signature.zones.filter((z) => z.index !== null);
// const ctx = canvas.getContext("2d");
// if (ctx && userSignatureZone.value) {
// //drawZone(userSignatureZone.value, ctx, canvas.width, canvas.height);
// }
await setPage(page.value); await setPage(page.value);
setTimeout(() => addZones(page.value), 200); setTimeout(() => drawAllZones(page.value), 200);
userSignatureZone.value = null; userSignatureZone.value = null;
adding.value = false;
canvasEvent.value = "select";
}; };
downloadAndOpen(); const toggleAddZone = () => {
canvasEvent.value === "select"
? (canvasEvent.value = "add")
: (canvasEvent.value = "select");
};
const addZoneEvent = async (e: PointerEvent, canvas: HTMLCanvasElement) => {
const BOX_WIDTH = 180;
const BOX_HEIGHT = 90;
const PDFPageHeight = canvas.height;
const PDFPageWidth = canvas.width;
const x = e.offsetX;
const y = e.offsetY;
const newZone: SignatureZone = {
index: null,
x:
scaleXToCanvas(x, canvas.width, PDFPageWidth) -
scaleXToCanvas(BOX_WIDTH / 2, canvas.width, PDFPageWidth),
y:
PDFPageHeight -
scaleYToCanvas(y, canvas.height, PDFPageHeight) +
scaleYToCanvas(BOX_HEIGHT / 2, canvas.height, PDFPageHeight),
width: BOX_WIDTH,
height: BOX_HEIGHT,
PDFPage: {
index: page.value - 1,
width: PDFPageWidth,
height: PDFPageHeight,
},
};
signature.zones.push(newZone);
userSignatureZone.value = newZone;
await setPage(page.value);
setTimeout(() => drawAllZones(page.value), 200);
canvasEvent.value = "select";
adding.value = true;
};
const getReturnPath = () =>
window.location.search
? window.location.search.split("?returnPath=")[1] ??
window.location.pathname
: window.location.pathname;
init();
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
#canvas { #canvas {
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.1); box-shadow: 0 20px 20px rgba(0, 0, 0, 0.2);
} }
div#action-buttons { div#action-buttons {
position: sticky; position: sticky;
@ -437,7 +613,15 @@ div#action-buttons {
background-color: white; background-color: white;
z-index: 100; z-index: 100;
} }
div#turn-page { div.pdf-tools {
background-color: #f3f3f3;
font-size: 0.8rem;
@media (min-width: 1400px) {
// background: none;
// border: none !important;
}
}
div.turn-page {
span { span {
font-size: 0.8rem; font-size: 0.8rem;
margin: 0 0.4rem; margin: 0 0.4rem;

View File

@ -10,13 +10,20 @@ const appMessages = {
you_are_going_to_sign: 'Vous allez signer le document', you_are_going_to_sign: 'Vous allez signer le document',
signature_confirmation: 'Confirmation de la signature', signature_confirmation: 'Confirmation de la signature',
sign: 'Signer', sign: 'Signer',
choose_another_signature: 'Choisir une autre zone de signature', choose_another_signature: 'Choisir une autre zone',
cancel: 'Annuler', cancel: 'Annuler',
cancel_signing: 'Refuser de signer', cancel_signing: 'Refuser de signer',
last_sign_zone: 'Zone de signature précédente', last_sign_zone: 'Zone de signature précédente',
next_sign_zone: 'Zone de signature suivante', next_sign_zone: 'Zone de signature suivante',
add_sign_zone: 'Ajouter une zone de signature',
last_zone: 'Zone précédente',
next_zone: 'Zone suivante',
add_zone: 'Ajouter une zone',
another_zone: 'Autre zone',
electronic_signature_in_progress: 'Signature électronique en cours...', electronic_signature_in_progress: 'Signature électronique en cours...',
loading: 'Chargement...' loading: 'Chargement...',
remove_sign_zone: 'Enlever la zone',
return: 'Retour',
} }
} }

View File

@ -18,7 +18,7 @@ final readonly class PdfSignedMessage
{ {
public function __construct( public function __construct(
public readonly int $signatureId, public readonly int $signatureId,
public readonly int $signatureZoneIndex, public readonly ?int $signatureZoneIndex,
public readonly string $content, public readonly string $content,
) {} ) {}
} }

View File

@ -21,7 +21,7 @@ final readonly class RequestPdfSignMessage
public function __construct( public function __construct(
public int $signatureId, public int $signatureId,
public PDFSignatureZone $PDFSignatureZone, public PDFSignatureZone $PDFSignatureZone,
public int $signatureZoneIndex, public ?int $signatureZoneIndex,
public string $reason, public string $reason,
public string $signerText, public string $signerText,
public string $content, public string $content,

View File

@ -17,7 +17,7 @@ final readonly class PDFSignatureZone
{ {
public function __construct( public function __construct(
#[Groups(['read'])] #[Groups(['read'])]
public int $index, public ?int $index,
#[Groups(['read'])] #[Groups(['read'])]
public float $x, public float $x,
#[Groups(['read'])] #[Groups(['read'])]

View File

@ -12,12 +12,15 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller; namespace Chill\MainBundle\Controller;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneAvailable; use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneAvailable;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment; use Twig\Environment;
@ -28,6 +31,7 @@ final readonly class WorkflowAddSignatureController
private PDFSignatureZoneAvailable $PDFSignatureZoneAvailable, private PDFSignatureZoneAvailable $PDFSignatureZoneAvailable,
private NormalizerInterface $normalizer, private NormalizerInterface $normalizer,
private Environment $twig, private Environment $twig,
private UrlGeneratorInterface $urlGenerator,
) {} ) {}
#[Route(path: '/{_locale}/main/workflow/signature/{id}/sign', name: 'chill_main_workflow_signature_add', methods: 'GET')] #[Route(path: '/{_locale}/main/workflow/signature/{id}/sign', name: 'chill_main_workflow_signature_add', methods: 'GET')]
@ -35,6 +39,16 @@ final readonly class WorkflowAddSignatureController
{ {
$entityWorkflow = $signature->getStep()->getEntityWorkflow(); $entityWorkflow = $signature->getStep()->getEntityWorkflow();
if (EntityWorkflowSignatureStateEnum::PENDING !== $signature->getState()) {
if ($request->query->has('returnPath')) {
return new RedirectResponse($request->query->get('returnPath'));
}
return new RedirectResponse(
$this->urlGenerator->generate('chill_main_workflow_show', ['id' => $entityWorkflow->getId()])
);
}
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow); $storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
if (null === $storedObject) { if (null === $storedObject) {
throw new NotFoundHttpException('No stored object found'); throw new NotFoundHttpException('No stored object found');

View File

@ -396,7 +396,10 @@ class WorkflowController extends AbstractController
} }
if ($signature->getSigner() instanceof User) { if ($signature->getSigner() instanceof User) {
return $this->redirectToRoute('chill_main_workflow_signature_add', ['id' => $signature_id]); return $this->redirectToRoute('chill_main_workflow_signature_add', [
'id' => $signature_id,
'returnPath' => $request->query->get('returnPath', null),
]);
} }
$metadataForm = $this->createForm(WorkflowSignatureMetadataType::class); $metadataForm = $this->createForm(WorkflowSignatureMetadataType::class);
@ -420,7 +423,10 @@ class WorkflowController extends AbstractController
$this->entityManager->persist($signature); $this->entityManager->persist($signature);
$this->entityManager->flush(); $this->entityManager->flush();
return $this->redirectToRoute('chill_main_workflow_signature_add', ['id' => $signature_id]); return $this->redirectToRoute('chill_main_workflow_signature_add', [
'id' => $signature_id,
'returnPath' => $request->query->get('returnPath', null),
]);
} }
return $this->render( return $this->render(

View File

@ -25,11 +25,9 @@
{% endblock %} {% endblock %}
<div class="content" id="content"> <div class="content" id="content">
<div class="container-xxl"> <div class="row">
<div class="row"> <div class="col-12 m-auto">
<div class="col-xs-12 col-md-12 col-lg-9 my-5 m-auto"> <div class="row" id="document-signature"></div>
<div class="row" id="document-signature"></div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -19,24 +19,7 @@ use Twig\TwigFilter;
*/ */
class ChillEntityRenderExtension extends AbstractExtension class ChillEntityRenderExtension extends AbstractExtension
{ {
/** public function __construct(private readonly ChillEntityRenderManagerInterface $renderManager) {}
* @var ChillEntityRender
*/
protected $defaultRender;
/**
* @var iterable|ChillEntityRenderInterface[]
*/
protected $renders = [];
/**
* ChillEntityRenderExtension constructor.
*/
public function __construct(iterable $renders)
{
$this->defaultRender = new ChillEntityRender();
$this->renders = $renders;
}
/** /**
* @return array|TwigFilter[] * @return array|TwigFilter[]
@ -53,34 +36,13 @@ class ChillEntityRenderExtension extends AbstractExtension
]; ];
} }
public function renderBox($entity, array $options = []): string public function renderBox(?object $entity, array $options = []): string
{ {
if (null === $entity) { return $this->renderManager->renderBox($entity, $options);
return '';
}
return $this->getRender($entity, $options)
->renderBox($entity, $options);
} }
public function renderString($entity, array $options = []): string public function renderString(?object $entity, array $options = []): string
{ {
if (null === $entity) { return $this->renderManager->renderString($entity, $options);
return '';
}
return $this->getRender($entity, $options)
->renderString($entity, $options);
}
protected function getRender($entity, $options): ?ChillEntityRenderInterface
{
foreach ($this->renders as $render) {
if ($render->supports($entity, $options)) {
return $render;
}
}
return $this->defaultRender;
} }
} }

View File

@ -15,7 +15,7 @@ namespace Chill\MainBundle\Templating\Entity;
* Interface to implement which will render an entity in template on a custom * Interface to implement which will render an entity in template on a custom
* manner. * manner.
* *
* @template T * @template T of object
*/ */
interface ChillEntityRenderInterface interface ChillEntityRenderInterface
{ {
@ -31,7 +31,7 @@ interface ChillEntityRenderInterface
* </span> * </span>
* ``` * ```
* *
* @param T $entity * @param T|null $entity
* *
* @phpstan-pure * @phpstan-pure
*/ */
@ -42,7 +42,7 @@ interface ChillEntityRenderInterface
* *
* Example: returning the name of a person. * Example: returning the name of a person.
* *
* @param T $entity * @param T|null $entity
* *
* @phpstan-pure * @phpstan-pure
*/ */

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Templating\Entity;
final readonly class ChillEntityRenderManager implements ChillEntityRenderManagerInterface
{
private ChillEntityRender $defaultRender;
public function __construct(/**
* @var iterable<ChillEntityRenderInterface>
*/
private iterable $renders)
{
$this->defaultRender = new ChillEntityRender();
}
public function renderBox($entity, array $options = []): string
{
if (null === $entity) {
return '';
}
return $this->getRender($entity, $options)
->renderBox($entity, $options);
}
public function renderString($entity, array $options = []): string
{
if (null === $entity) {
return '';
}
return $this->getRender($entity, $options)
->renderString($entity, $options);
}
private function getRender($entity, $options): ChillEntityRenderInterface
{
foreach ($this->renders as $render) {
if ($render->supports($entity, $options)) {
return $render;
}
}
return $this->defaultRender;
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Templating\Entity;
interface ChillEntityRenderManagerInterface
{
public function renderBox(?object $entity, array $options = []): string;
public function renderString(?object $entity, array $options = []): string;
}

View File

@ -23,6 +23,7 @@ use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment; use Twig\Environment;
@ -62,7 +63,9 @@ class WorkflowAddSignatureControllerTest extends TestCase
$twig->method('render')->with('@ChillMain/Workflow/_signature_sign.html.twig', $this->isType('array')) $twig->method('render')->with('@ChillMain/Workflow/_signature_sign.html.twig', $this->isType('array'))
->willReturn('ok'); ->willReturn('ok');
$controller = new WorkflowAddSignatureController($entityWorkflowManager, $pdfSignatureZoneAvailable, $normalizer, $twig); $urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$controller = new WorkflowAddSignatureController($entityWorkflowManager, $pdfSignatureZoneAvailable, $normalizer, $twig, $urlGenerator);
$actual = $controller($signature, new Request()); $actual = $controller($signature, new Request());

View File

@ -32,11 +32,16 @@ services:
- { name: twig.extension } - { name: twig.extension }
Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension: Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension:
arguments:
$renders: !tagged_iterator chill.render_entity
tags: tags:
- { name: twig.extension } - { name: twig.extension }
Chill\MainBundle\Templating\Entity\ChillEntityRenderManager:
arguments:
$renders: !tagged_iterator chill.render_entity
Chill\MainBundle\Templating\Entity\ChillEntityRenderManagerInterface:
alias: 'Chill\MainBundle\Templating\Entity\ChillEntityRenderManager'
Chill\MainBundle\Templating\Entity\CommentRender: Chill\MainBundle\Templating\Entity\CommentRender:
tags: tags:
- { name: 'chill.render_entity' } - { name: 'chill.render_entity' }