@libresign/pdf-elements 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@libresign/pdf-elements",
3
+ "description": "PDF viewer with draggable and resizable element overlays for Vue 2",
4
+ "version": "0.1.0",
5
+ "author": "LibreCode <contact@librecode.coop>",
6
+ "private": false,
7
+ "main": "dist/pdf-elements.umd.js",
8
+ "module": "dist/pdf-elements.esm.js",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/LibreSign/pdf-elements"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/LibreSign/pdf-elements/issues"
15
+ },
16
+ "homepage": "https://github.com/LibreSign/pdf-elements#readme",
17
+ "keywords": [
18
+ "pdf",
19
+ "viewer",
20
+ "annotations",
21
+ "draggable",
22
+ "resizable",
23
+ "libresign",
24
+ "vue2"
25
+ ],
26
+ "scripts": {
27
+ "serve": "vue-cli-service serve",
28
+ "build": "vue-cli-service build",
29
+ "build:lib": "vue-cli-service build --target lib --name pdf-elements src/index.js",
30
+ "lint": "vue-cli-service lint --no-fix",
31
+ "lint:fix": "vue-cli-service lint"
32
+ },
33
+ "dependencies": {
34
+ "debounce": "^3.0.0",
35
+ "pdfjs-dist": "^5.4.530",
36
+ "vue": "^2.7.16"
37
+ },
38
+ "devDependencies": {
39
+ "@babel/core": "^7.28.6",
40
+ "@babel/eslint-parser": "^7.28.6",
41
+ "@babel/plugin-transform-private-methods": "^7.28.6",
42
+ "@nextcloud/browserslist-config": "^3.1.2",
43
+ "@vue/cli-plugin-babel": "^5.0.9",
44
+ "@vue/cli-plugin-eslint": "^5.0.9",
45
+ "@vue/cli-service": "^5.0.9",
46
+ "eslint": "^8.57.1",
47
+ "eslint-plugin-vue": "^10.6.2",
48
+ "postcss": "^8.5.6",
49
+ "vue-template-compiler": "^2.7.16"
50
+ },
51
+ "browserslist": [
52
+ "extends @nextcloud/browserslist-config"
53
+ ],
54
+ "files": [
55
+ "dist",
56
+ "src",
57
+ "COPYING",
58
+ "README.md"
59
+ ],
60
+ "license": "AGPL-3.0-or-later"
61
+ }
@@ -0,0 +1,505 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3
+ SPDX-License-Identifier: AGPL-3.0-or-later
4
+ -->
5
+
6
+ <template>
7
+ <div class="draggable-wrapper">
8
+ <div
9
+ v-if="isSelected && !isBeingDraggedGlobally && showSelectionUi && showDefaultActions"
10
+ class="actions-toolbar"
11
+ :style="toolbarStyle"
12
+ >
13
+ <slot name="actions" :object="object" :onDelete="onDelete">
14
+ <button class="action-btn" type="button" title="Delete" @click.stop="onDelete">
15
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
16
+ <path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.5a.5.5 0 0 0 0 1h.5v10.5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5V3.5h.5a.5.5 0 0 0 0-1H11Zm1 1v10.5a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5V3.5h8Z"/>
17
+ </svg>
18
+ </button>
19
+ </slot>
20
+ </div>
21
+ <div
22
+ class="draggable-element"
23
+ draggable="false"
24
+ @dragstart.prevent
25
+ :class="{ selected: isSelected && showSelectionUi }"
26
+ :style="[elementStyle, dragElementStyle]"
27
+ @mousedown="handleElementClick"
28
+ @touchstart="handleElementClick"
29
+ >
30
+ <slot
31
+ :object="object"
32
+ :isSelected="isSelected"
33
+ :onDelete="onDelete"
34
+ :onResize="startResizeFromSlot"
35
+ />
36
+
37
+ <template v-if="isSelected && showSelectionUi">
38
+ <button
39
+ v-for="dir in resizeDirections"
40
+ :key="dir"
41
+ class="resize-handle"
42
+ :class="`handle-${dir}`"
43
+ type="button"
44
+ @mousedown.stop.prevent="startResize(dir, $event)"
45
+ @touchstart.stop.prevent="startResize(dir, $event)"
46
+ />
47
+ </template>
48
+ </div>
49
+ </div>
50
+ </template>
51
+
52
+ <script>
53
+ export default {
54
+ name: 'DraggableElement',
55
+ props: {
56
+ object: {
57
+ type: Object,
58
+ required: true,
59
+ },
60
+ pagesScale: {
61
+ type: Number,
62
+ default: 1,
63
+ },
64
+ pageWidth: {
65
+ type: Number,
66
+ required: true,
67
+ },
68
+ pageHeight: {
69
+ type: Number,
70
+ required: true,
71
+ },
72
+ onUpdate: {
73
+ type: Function,
74
+ default: () => {},
75
+ },
76
+ onDelete: {
77
+ type: Function,
78
+ default: () => {},
79
+ },
80
+ onDragStart: {
81
+ type: Function,
82
+ default: () => {},
83
+ },
84
+ onDragMove: {
85
+ type: Function,
86
+ default: () => {},
87
+ },
88
+ onDragEnd: {
89
+ type: Function,
90
+ default: () => {},
91
+ },
92
+ isBeingDraggedGlobally: {
93
+ type: Boolean,
94
+ default: false,
95
+ },
96
+ draggingClientPos: {
97
+ type: Object,
98
+ default: () => ({ x: 0, y: 0 }),
99
+ },
100
+ currentDocIndex: {
101
+ type: Number,
102
+ default: -1,
103
+ },
104
+ currentPageIndex: {
105
+ type: Number,
106
+ default: -1,
107
+ },
108
+ globalDragDocIndex: {
109
+ type: Number,
110
+ default: -1,
111
+ },
112
+ globalDragPageIndex: {
113
+ type: Number,
114
+ default: -1,
115
+ },
116
+ showSelectionUi: {
117
+ type: Boolean,
118
+ default: true,
119
+ },
120
+ showDefaultActions: {
121
+ type: Boolean,
122
+ default: true,
123
+ }
124
+ },
125
+ data() {
126
+ return {
127
+ isSelected: false,
128
+ mode: 'idle',
129
+ direction: '',
130
+ startX: 0,
131
+ startY: 0,
132
+ startLeft: 0,
133
+ startTop: 0,
134
+ startWidth: 0,
135
+ startHeight: 0,
136
+ offsetX: 0,
137
+ offsetY: 0,
138
+ resizeOffsetX: 0,
139
+ resizeOffsetY: 0,
140
+ resizeOffsetW: 0,
141
+ resizeOffsetH: 0,
142
+ aspectRatio: 1,
143
+ lastMouseX: 0,
144
+ lastMouseY: 0,
145
+ pointerOffsetDoc: { x: 0, y: 0 },
146
+ currentPageRect: null,
147
+ rafId: null,
148
+ }
149
+ },
150
+ computed: {
151
+ resizeDirections() {
152
+ return ['top-left', 'top-right', 'bottom-left', 'bottom-right']
153
+ },
154
+ elementStyle() {
155
+ const scale = this.pagesScale || 1
156
+ const currentX = this.object.x + this.offsetX + this.resizeOffsetX
157
+ const currentY = this.object.y + this.offsetY + this.resizeOffsetY
158
+ const currentWidth = this.object.width + this.resizeOffsetW
159
+ const currentHeight = this.object.height + this.resizeOffsetH
160
+ return {
161
+ left: `${currentX * scale}px`,
162
+ top: `${currentY * scale}px`,
163
+ width: `${currentWidth * scale}px`,
164
+ height: `${currentHeight * scale}px`,
165
+ }
166
+ },
167
+ toolbarStyle() {
168
+ const scale = this.pagesScale || 1
169
+ const x = this.object.x + this.offsetX + this.resizeOffsetX
170
+ const y = this.object.y + this.offsetY + this.resizeOffsetY
171
+ const width = this.object.width + this.resizeOffsetW
172
+ return {
173
+ left: `${(x + width / 2) * scale}px`,
174
+ top: `${(y - 48) * scale}px`,
175
+ transform: 'translateX(-50%)',
176
+ }
177
+ },
178
+ dragElementStyle() {
179
+ if (!this.isBeingDraggedGlobally || !this.draggingClientPos) {
180
+ return {}
181
+ }
182
+ return {
183
+ opacity: 0,
184
+ pointerEvents: 'none',
185
+ }
186
+ },
187
+ },
188
+ mounted() {
189
+ this.handleClickOutside = this.handleClickOutside.bind(this)
190
+ this.boundHandleMove = this.handleMove.bind(this)
191
+ this.boundStopInteraction = this.stopInteraction.bind(this)
192
+ document.addEventListener('mousedown', this.handleClickOutside)
193
+ document.addEventListener('touchstart', this.handleClickOutside)
194
+ },
195
+ beforeUnmount() {
196
+ document.removeEventListener('mousedown', this.handleClickOutside)
197
+ document.removeEventListener('touchstart', this.handleClickOutside)
198
+ window.removeEventListener('mousemove', this.boundHandleMove)
199
+ window.removeEventListener('mouseup', this.boundStopInteraction)
200
+ window.removeEventListener('touchmove', this.boundHandleMove)
201
+ window.removeEventListener('touchend', this.boundStopInteraction)
202
+ if (this.rafId) cancelAnimationFrame(this.rafId)
203
+ },
204
+ methods: {
205
+ handleElementClick(event) {
206
+ if (event.target.closest('.delete-handle') || event.target.closest('[data-stop-drag]') || event.target.closest('.actions-toolbar')) {
207
+ return
208
+ }
209
+ event.preventDefault()
210
+ this.isSelected = true
211
+ this.startDrag(event)
212
+ },
213
+ handleClickOutside(event) {
214
+ if (this.$el && !this.$el.contains(event.target)) {
215
+ this.isSelected = false
216
+ }
217
+ },
218
+ startResizeFromSlot(direction, event) {
219
+ if (!direction || !event) return
220
+ this.startResize(direction, event)
221
+ },
222
+ startDrag(event) {
223
+ if (event.target.classList.contains('delete')) return
224
+ if (event.target.classList.contains('resize-handle')) return
225
+ this.mode = 'drag'
226
+ this.startX = event.type.includes('touch') ? event.touches[0].clientX : event.clientX
227
+ this.startY = event.type.includes('touch') ? event.touches[0].clientY : event.clientY
228
+ this.startLeft = this.object.x
229
+ this.startTop = this.object.y
230
+ this.offsetX = 0
231
+ this.offsetY = 0
232
+ this.resetResizeOffsets()
233
+
234
+ const elementRect = this.$el.querySelector('.draggable-element').getBoundingClientRect()
235
+
236
+ this.pointerOffsetDoc.x = this.startX - elementRect.left
237
+ this.pointerOffsetDoc.y = this.startY - elementRect.top
238
+
239
+ const pageRect = this.capturePageRect()
240
+ this.currentPageRect = pageRect
241
+ let dragElementShift = { x: 0, y: 0 }
242
+ if (pageRect) {
243
+ const expectedLeft = pageRect.left + (this.object.x * this.pagesScale)
244
+ const expectedTop = pageRect.top + (this.object.y * this.pagesScale)
245
+ dragElementShift = {
246
+ x: elementRect.left - expectedLeft,
247
+ y: elementRect.top - expectedTop,
248
+ }
249
+ }
250
+
251
+ this.onDragStart(this.startX, this.startY, { ...this.pointerOffsetDoc }, dragElementShift)
252
+
253
+ window.addEventListener('mousemove', this.boundHandleMove)
254
+ window.addEventListener('mouseup', this.boundStopInteraction)
255
+ window.addEventListener('touchmove', this.boundHandleMove)
256
+ window.addEventListener('touchend', this.boundStopInteraction)
257
+ },
258
+ startResize(direction, event) {
259
+ this.mode = 'resize'
260
+ this.direction = direction
261
+ this.startX = event.type.includes('touch') ? event.touches[0].clientX : event.clientX
262
+ this.startY = event.type.includes('touch') ? event.touches[0].clientY : event.clientY
263
+ this.startLeft = this.object.x
264
+ this.startTop = this.object.y
265
+ this.startWidth = this.object.width
266
+ this.startHeight = this.object.height
267
+ this.aspectRatio = this.startWidth / this.startHeight
268
+ this.offsetX = 0
269
+ this.offsetY = 0
270
+ this.resetResizeOffsets()
271
+
272
+ window.addEventListener('mousemove', this.boundHandleMove)
273
+ window.addEventListener('mouseup', this.boundStopInteraction)
274
+ window.addEventListener('touchmove', this.boundHandleMove)
275
+ window.addEventListener('touchend', this.boundStopInteraction)
276
+ },
277
+ handleMove(event) {
278
+ if (this.mode === 'idle') return
279
+ event.preventDefault()
280
+
281
+ if (this.rafId) return
282
+
283
+ this.rafId = requestAnimationFrame(() => {
284
+ const currentX = event.type.includes('touch') ? event.touches[0]?.clientX : event.clientX
285
+ const currentY = event.type.includes('touch') ? event.touches[0]?.clientY : event.clientY
286
+
287
+ if (currentX === undefined || currentY === undefined) return
288
+
289
+ this.lastMouseX = currentX
290
+ this.lastMouseY = currentY
291
+ const deltaX = (currentX - this.startX) / this.pagesScale
292
+ const deltaY = (currentY - this.startY) / this.pagesScale
293
+
294
+ if (this.mode === 'drag') {
295
+ const pageRect = this.currentPageRect
296
+ if (pageRect) {
297
+ const newElementLeft = currentX - this.pointerOffsetDoc.x
298
+ const newElementTop = currentY - this.pointerOffsetDoc.y
299
+
300
+ const newX = (newElementLeft - pageRect.left) / this.pagesScale
301
+ const newY = (newElementTop - pageRect.top) / this.pagesScale
302
+
303
+ this.offsetX = newX - this.object.x
304
+ this.offsetY = newY - this.object.y
305
+ } else {
306
+ this.offsetX = deltaX
307
+ this.offsetY = deltaY
308
+ }
309
+ this.onDragMove(currentX, currentY)
310
+ if (this.isBeingDraggedGlobally) {
311
+ this.onUpdate({
312
+ _globalDrag: true,
313
+ _mouseX: currentX,
314
+ _mouseY: currentY,
315
+ })
316
+ }
317
+ this.rafId = null
318
+ return
319
+ }
320
+
321
+ const minSize = 16
322
+ let newWidth = this.startWidth
323
+ let newHeight = this.startHeight
324
+ let newLeft = this.startLeft
325
+ let newTop = this.startTop
326
+
327
+ const widthChange = this.direction.includes('right') ? deltaX : this.direction.includes('left') ? -deltaX : 0
328
+ newWidth = this.startWidth + widthChange
329
+ if (newWidth < minSize) newWidth = minSize
330
+ newHeight = newWidth / this.aspectRatio
331
+
332
+ if (this.direction.includes('left')) {
333
+ newLeft = this.startLeft + (this.startWidth - newWidth)
334
+ if (newLeft < 0) {
335
+ const overflow = -newLeft
336
+ newLeft = 0
337
+ newWidth = newWidth - overflow
338
+ newHeight = newWidth / this.aspectRatio
339
+ }
340
+ }
341
+
342
+ if (this.direction.includes('top')) {
343
+ newTop = this.startTop + (this.startHeight - newHeight)
344
+ if (newTop < 0) {
345
+ const overflow = -newTop
346
+ newTop = 0
347
+ newHeight = newHeight - overflow
348
+ newWidth = newHeight * this.aspectRatio
349
+ if (this.direction.includes('left')) {
350
+ newLeft = this.startLeft + (this.startWidth - newWidth)
351
+ }
352
+ }
353
+ }
354
+
355
+ if (newLeft + newWidth > this.pageWidth) {
356
+ const excess = newLeft + newWidth - this.pageWidth
357
+ newWidth -= excess
358
+ newHeight = newWidth / this.aspectRatio
359
+ }
360
+ if (newTop + newHeight > this.pageHeight) {
361
+ const excess = newTop + newHeight - this.pageHeight
362
+ newHeight -= excess
363
+ newWidth = newHeight * this.aspectRatio
364
+ if (this.direction.includes('left')) {
365
+ newLeft = this.startLeft + (this.startWidth - newWidth)
366
+ }
367
+ }
368
+
369
+ this.resizeOffsetX = newLeft - this.object.x
370
+ this.resizeOffsetY = newTop - this.object.y
371
+ this.resizeOffsetW = newWidth - this.object.width
372
+ this.resizeOffsetH = newHeight - this.object.height
373
+
374
+ this.rafId = null
375
+ })
376
+ },
377
+ stopInteraction() {
378
+ if (this.mode === 'idle') return
379
+
380
+ if (this.mode === 'drag' && (this.offsetX !== 0 || this.offsetY !== 0)) {
381
+ if (this.isBeingDraggedGlobally) {
382
+ this.onUpdate({
383
+ _globalDrag: true,
384
+ _mouseX: this.lastMouseX,
385
+ _mouseY: this.lastMouseY,
386
+ })
387
+ } else {
388
+ const x = Math.max(0, Math.min(this.object.x + this.offsetX, this.pageWidth - this.object.width))
389
+ const y = Math.max(0, Math.min(this.object.y + this.offsetY, this.pageHeight - this.object.height))
390
+ this.onUpdate({ x, y })
391
+ }
392
+ }
393
+
394
+ if (this.mode === 'resize' && (this.resizeOffsetW !== 0 || this.resizeOffsetH !== 0 || this.resizeOffsetX !== 0 || this.resizeOffsetY !== 0)) {
395
+ const x = this.object.x + this.resizeOffsetX
396
+ const y = this.object.y + this.resizeOffsetY
397
+ const width = this.object.width + this.resizeOffsetW
398
+ const height = this.object.height + this.resizeOffsetH
399
+ this.onUpdate({ x, y, width, height })
400
+ }
401
+
402
+ this.resetOffsets()
403
+ this.onDragEnd()
404
+ window.removeEventListener('mousemove', this.boundHandleMove)
405
+ window.removeEventListener('mouseup', this.boundStopInteraction)
406
+ window.removeEventListener('touchmove', this.boundHandleMove)
407
+ window.removeEventListener('touchend', this.boundStopInteraction)
408
+ },
409
+ capturePageRect() {
410
+ const wrapper = this.$el.closest('.page-wrapper')
411
+ if (!wrapper) return null
412
+ const canvas = wrapper.querySelector('canvas')
413
+ return canvas ? canvas.getBoundingClientRect() : null
414
+ },
415
+ resetResizeOffsets() {
416
+ this.resizeOffsetX = 0
417
+ this.resizeOffsetY = 0
418
+ this.resizeOffsetW = 0
419
+ this.resizeOffsetH = 0
420
+ },
421
+ resetOffsets() {
422
+ this.mode = 'idle'
423
+ this.offsetX = 0
424
+ this.offsetY = 0
425
+ this.resetResizeOffsets()
426
+ this.pointerOffsetDoc = { x: 0, y: 0 }
427
+ this.currentPageRect = null
428
+ },
429
+ },
430
+ }
431
+ </script>
432
+
433
+ <style scoped>
434
+ .draggable-wrapper {
435
+ position: relative;
436
+ }
437
+ .actions-toolbar {
438
+ position: absolute;
439
+ display: flex;
440
+ gap: 4px;
441
+ background: #1f2937;
442
+ border-radius: 6px;
443
+ padding: 6px 8px;
444
+ box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.15), 0 2px 6px -2px rgba(0, 0, 0, 0.1);
445
+ z-index: 100;
446
+ white-space: nowrap;
447
+ }
448
+ .action-btn {
449
+ border: none;
450
+ background: transparent;
451
+ color: #ffffff;
452
+ padding: 4px;
453
+ border-radius: 4px;
454
+ cursor: pointer;
455
+ display: flex;
456
+ align-items: center;
457
+ justify-content: center;
458
+ transition: background 120ms ease;
459
+ }
460
+ .action-btn:hover {
461
+ background: rgba(255, 255, 255, 0.1);
462
+ }
463
+ .draggable-element {
464
+ position: absolute;
465
+ cursor: move;
466
+ user-select: none;
467
+ border-radius: 6px;
468
+ overflow: visible;
469
+ }
470
+ .draggable-element.selected {
471
+ box-shadow: inset 0 0 0 2px #2563eb;
472
+ }
473
+ .resize-handle {
474
+ position: absolute;
475
+ width: 10px;
476
+ height: 10px;
477
+ background: #2563eb;
478
+ border: 1px solid #ffffff;
479
+ border-radius: 2px;
480
+ padding: 0;
481
+ margin: 0;
482
+ cursor: pointer;
483
+ z-index: 200;
484
+ }
485
+ .handle-top-left {
486
+ top: -6px;
487
+ left: -6px;
488
+ cursor: nwse-resize;
489
+ }
490
+ .handle-top-right {
491
+ top: -6px;
492
+ right: -6px;
493
+ cursor: nesw-resize;
494
+ }
495
+ .handle-bottom-left {
496
+ bottom: -6px;
497
+ left: -6px;
498
+ cursor: nesw-resize;
499
+ }
500
+ .handle-bottom-right {
501
+ bottom: -6px;
502
+ right: -6px;
503
+ cursor: nwse-resize;
504
+ }
505
+ </style>