From 2dd705e563a2dfd36b5f8645aa31ce4a917461f1 Mon Sep 17 00:00:00 2001 From: Michael Delaporte Date: Mon, 21 Jul 2025 13:46:45 +0200 Subject: [PATCH 1/3] Implement shape annotation drawing with rectangle and circle --- src/components/mixins/annotation.js | 219 ++++++++++++++++++++++ src/components/previews/PreviewPlayer.vue | 51 +++++ src/components/widgets/ButtonSimple.vue | 9 + src/components/widgets/ShapePicker.vue | 121 ++++++++++++ 4 files changed, 400 insertions(+) create mode 100644 src/components/widgets/ShapePicker.vue diff --git a/src/components/mixins/annotation.js b/src/components/mixins/annotation.js index a9b1442def..948d9160b0 100644 --- a/src/components/mixins/annotation.js +++ b/src/components/mixins/annotation.js @@ -221,6 +221,36 @@ export const annotationMixin = { } }, + addShape(event) { + if (this.fabricCanvas.getActiveObject()) return + const canvas = this.canvas || this.canvasWrapper + const offsetCanvas = canvas.getBoundingClientRect() + const posX = this.getClientX(event) - offsetCanvas.x + const posY = this.getClientY(event) - offsetCanvas.y + const baseHeight = 320 + let fontSize = 12 + if (this.fabricCanvas.getHeight() > baseHeight) { + fontSize = fontSize * (this.fabricCanvas.getHeight() / baseHeight) + } + const fabricText = new fabric.IText('Type...', { + left: posX, + top: posY, + fontFamily: 'arial', + fill: this.textColor, + fontSize: fontSize, + backgroundColor: 'rgba(255,255,255, 0.8)', + padding: 10 + }) + + this.fabricCanvas.add(fabricText) + this.fabricCanvas.setActiveObject(fabricText) + fabricText.enterEditing() + fabricText.selectAll() + fabricText.hiddenTextarea.onblur = () => { + this.saveAnnotations() + } + }, + /** @lends fabric.IText.prototype */ // fix for : IText not editable when canvas is in a fullscreen // element on chrome @@ -754,6 +784,14 @@ export const annotationMixin = { this.isShowingPalette = !this.isShowingPalette }, + onPickShapeColor() { + this.isShowingPalette = !this.isShowingPalette + }, + + onPickShape() { + // TODO + }, + /* * When a drawing color is changed, change fabric configuration and save * the new color in the local preferences. @@ -786,6 +824,19 @@ export const annotationMixin = { localPreferences.setPreference('player:text-color', this.textColor) }, + onChangeShapeColor(newValue) { + this.shapeColor = newValue + this.isShowingPalette = false + localPreferences.setPreference('player:shape-color', this.shapeColor) + }, + + onChangeShape(newValue) { + console.log('onChangeShape', newValue) + this.shape = newValue + this.isShowingPalette = false + localPreferences.setPreference('player:shape', this.shape) + }, + _resetColor() { if (!this.fabricCanvas) return this.fabricCanvas.freeDrawingBrush.color = this.pencilColor @@ -821,6 +872,7 @@ export const annotationMixin = { * Enable / disable the drawing mode. Differentiate text from path drawing. */ onAnnotateClicked() { + console.log('onAnnotateClicked', this.isDrawing) this.showCanvas() if (this.isDrawing) { this.fabricCanvas.isDrawingMode = false @@ -844,6 +896,173 @@ export const annotationMixin = { } }, + onDrawShapeClicked() { + console.log('onDrawShapeClicked', this.isDrawingShape) + // this.showCanvas() + if (this.isDrawingShape) { + this.disableCurrentTool() + } else { + this.disableCurrentTool() + this.isDrawingShape = true + if (this.fabricCanvas) { + // this.fabricCanvas.isDrawingMode = true + this.fabricCanvas.selection = false + this.fabricCanvas.skipTargetFind = true + } + console.log('addEventListener', this.canvasWrapper) + this.canvasWrapper.addEventListener('mousedown', this.startDrawingShape) + this.canvasWrapper.addEventListener( + 'mousemove', + this.updateDrawingShape + ) + this.canvasWrapper.addEventListener('mouseup', this.endDrawingShape) + } + }, + + startDrawingShape(event) { + const canvas = this.canvas || this.canvasWrapper + const offsetCanvas = canvas.getBoundingClientRect() + const posX = this.getClientX(event) - offsetCanvas.x + const posY = this.getClientY(event) - offsetCanvas.y + this.shapeStartPos = { x: posX, y: posY } + console.log('startDrawingShape', this.shape, posX, posY, this.shapeColor) + if (this.shape === 'rectangle') { + this.drawingShape = new fabric.Rect({ + left: posX, + top: posY, + stroke: this.shapeColor, + fill: 'rgba(128, 128, 128, 0.25)', + width: 1, + height: 1 + }) + } else if (this.shape === 'circle') { + this.drawingShape = new fabric.Circle({ + left: posX, + top: posY, + stroke: this.shapeColor, + fill: 'rgba(128, 128, 128, 0.25)', + radius: 1 + }) + } else { + console.error('Unknown shape type:', this.shape) + return + } + this.fabricCanvas.add(this.drawingShape) + }, + + updateDrawingShape(event) { + if (!this.drawingShape) return + const canvas = this.canvas || this.canvasWrapper + const offsetCanvas = canvas.getBoundingClientRect() + const posX = this.getClientX(event) - offsetCanvas.x + const posY = this.getClientY(event) - offsetCanvas.y + // console.log('updateDrawingShape', this.shape, posX, posY) + if (this.shape === 'rectangle') { + const deltaX = posX - this.shapeStartPos.x + const deltaY = posY - this.shapeStartPos.y + + // Just reset all the coordinates from the start position and radius (adjust left and top depending if deltaX or deltaY is negative) + let top, + left = 0 + const width = Math.abs(deltaX) + const height = Math.abs(deltaY) + if (deltaX < 0) { + left = this.shapeStartPos.x - width + } else { + left = this.shapeStartPos.x + } + if (deltaY < 0) { + top = this.shapeStartPos.y - height + } else { + top = this.shapeStartPos.y + } + this.drawingShape.set({ + left, + top, + width, + height + }) + } else if (this.shape === 'circle') { + const deltaX = posX - this.shapeStartPos.x + const deltaY = posY - this.shapeStartPos.y + + // Just reset all the coordinates from the start position and radius (adjust left and top depending if deltaX or deltaY is negative) + let top, + left = 0 + const delta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaY : deltaX + const radius = Math.abs(delta) + if (deltaX < 0) { + left = this.shapeStartPos.x - radius + } else { + left = this.shapeStartPos.x + } + if (deltaY < 0) { + top = this.shapeStartPos.y - radius + } else { + top = this.shapeStartPos.y + } + console.log({ + deltaX, + deltaY, + left, + top, + delta, + radius + }) + this.drawingShape.set({ + left: left, + top: top, + radius: radius * 0.5 + }) + } else { + console.error('Unknown shape type:', this.shape) + return + } + // Update the shape's coordinates and render the canvas + this.drawingShape.setCoords() + this.fabricCanvas.renderAll() + }, + + endDrawingShape() { + if (!this.drawingShape) return + console.log('endDrawingShape', this.shape, this.drawingShape) + this.drawingShape.set({ + stroke: this.shapeColor, + strokeWidth: 2, + fill: 'transparent' + }) + this.drawingShape.setCoords() + this.fabricCanvas.renderAll() + // TODO serialize shape + this.drawingShape = null + }, + + disableCurrentTool() { + const canvas = this.canvas || this.canvasWrapper + const clickarea = canvas.getElementsByClassName('upper-canvas')[0] + if (this.isDrawing) { + this.fabricCanvas.isDrawingMode = false + this.isDrawing = false + } else if (this.isTyping) { + this.isTyping = false + clickarea.removeEventListener('dblclick', this.addText) + } else if (this.isDrawingShape) { + this.isDrawingShape = false + this.fabricCanvas.isDrawingMode = false + this.canvasWrapper.removeEventListener( + 'mousedown', + this.startDrawingShape + ) + this.canvasWrapper.removeEventListener( + 'mousemove', + this.updateDrawingShape + ) + this.canvasWrapper.removeEventListener('mouseup', this.endDrawingShape) + } + this.fabricCanvas.selection = true + this.fabricCanvas.skipTargetFind = false + }, + /* * Enable / disable eraser mode. */ diff --git a/src/components/previews/PreviewPlayer.vue b/src/components/previews/PreviewPlayer.vue index b852b3b034..1365e4a7ea 100644 --- a/src/components/previews/PreviewPlayer.vue +++ b/src/components/previews/PreviewPlayer.vue @@ -312,6 +312,33 @@ v-if="!readOnly && fullScreen && !isConcept" /> + +
+ + +
+
+ + +
+ + + @@ -75,6 +78,7 @@ import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, + CircleIcon, ClockIcon, CodepenIcon, CornerLeftDownIcon, @@ -98,8 +102,10 @@ import { PaperclipIcon, PauseIcon, PlusIcon, + RectangleHorizontalIcon, SaveIcon, SendIcon, + ShapesIcon, SkipBackIcon, SkipForwardIcon, SquareIcon, @@ -141,8 +147,11 @@ export default { PaperclipIcon, PauseIcon, PlusIcon, + RectangleHorizontalIcon, + CircleIcon, SaveIcon, SendIcon, + ShapesIcon, SkipBackIcon, SkipForwardIcon, SquareIcon, diff --git a/src/components/widgets/ShapePicker.vue b/src/components/widgets/ShapePicker.vue new file mode 100644 index 0000000000..1a59ba205c --- /dev/null +++ b/src/components/widgets/ShapePicker.vue @@ -0,0 +1,121 @@ + + + + + From ad525546b6b83eb752865d51980b1fdf57adaa2a Mon Sep 17 00:00:00 2001 From: Michael Delaporte Date: Mon, 21 Jul 2025 14:18:19 +0200 Subject: [PATCH 2/3] shape annotation: implement pencil-picker for stroke witdth --- src/components/mixins/annotation.js | 17 ++++++++++++++--- src/components/previews/PreviewPlayer.vue | 6 ++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/mixins/annotation.js b/src/components/mixins/annotation.js index 948d9160b0..af72cddbc0 100644 --- a/src/components/mixins/annotation.js +++ b/src/components/mixins/annotation.js @@ -933,7 +933,13 @@ export const annotationMixin = { stroke: this.shapeColor, fill: 'rgba(128, 128, 128, 0.25)', width: 1, - height: 1 + height: 1, + strokeWidth: + this.pencilWidth === 'big' + ? 10 + : this.pencilWidth === 'medium' + ? 5 + : 2 }) } else if (this.shape === 'circle') { this.drawingShape = new fabric.Circle({ @@ -941,7 +947,13 @@ export const annotationMixin = { top: posY, stroke: this.shapeColor, fill: 'rgba(128, 128, 128, 0.25)', - radius: 1 + radius: 1, + strokeWidth: + this.pencilWidth === 'big' + ? 10 + : this.pencilWidth === 'medium' + ? 5 + : 2 }) } else { console.error('Unknown shape type:', this.shape) @@ -1028,7 +1040,6 @@ export const annotationMixin = { console.log('endDrawingShape', this.shape, this.drawingShape) this.drawingShape.set({ stroke: this.shapeColor, - strokeWidth: 2, fill: 'transparent' }) this.drawingShape.setCoords() diff --git a/src/components/previews/PreviewPlayer.vue b/src/components/previews/PreviewPlayer.vue index 1365e4a7ea..da7fb65299 100644 --- a/src/components/previews/PreviewPlayer.vue +++ b/src/components/previews/PreviewPlayer.vue @@ -317,6 +317,12 @@ class="annotation-tools" v-show="isDrawingShape && (!light || fullScreen)" > + Date: Mon, 1 Sep 2025 12:50:10 +0200 Subject: [PATCH 3/3] shape annotation: implement arrow shape, and stroke width (WIP) --- src/components/examples/AnnotationExample.vue | 263 ++++++++++++ src/components/mixins/annotation.js | 105 ++++- src/components/previews/PreviewPlayer.vue | 8 +- src/components/widgets/ShapePicker.vue | 13 +- src/lib/arrowshape.js | 390 ++++++++++++++++++ 5 files changed, 762 insertions(+), 17 deletions(-) create mode 100644 src/components/examples/AnnotationExample.vue create mode 100644 src/lib/arrowshape.js diff --git a/src/components/examples/AnnotationExample.vue b/src/components/examples/AnnotationExample.vue new file mode 100644 index 0000000000..8006fe0e20 --- /dev/null +++ b/src/components/examples/AnnotationExample.vue @@ -0,0 +1,263 @@ + + + + + + diff --git a/src/components/mixins/annotation.js b/src/components/mixins/annotation.js index af72cddbc0..da816e19e7 100644 --- a/src/components/mixins/annotation.js +++ b/src/components/mixins/annotation.js @@ -11,6 +11,10 @@ import { markRaw } from 'vue' import clipboard from '@/lib/clipboard' import { formatFullDate } from '@/lib/time' import localPreferences from '@/lib/preferences' +import { registerArrowFabricShape } from '@/lib/arrowshape' + +// Register custom Arrow shape with Fabric.js +registerArrowFabricShape() /* Monkey patch needed to have text background including the padding. */ if (fabric) { @@ -744,6 +748,30 @@ export const annotationMixin = { canvas.add(psstroke) this.$options.silentAnnnotation = false } + } else if (obj.type === 'arrow') { + // Handle custom Arrow object + const points = [ + obj.x1 * scaleMultiplierX, + obj.y1 * scaleMultiplierY, + obj.x2 * scaleMultiplierX, + obj.y2 * scaleMultiplierY + ] + const arrow = new fabric.Arrow(points, { + ...base, + arrowHeadSize: obj.arrowHeadSize || 15, + arrowHeadWidth: obj.arrowHeadWidth || 12, + stroke: obj.stroke, + strokeWidth: obj.strokeWidth + }) + + arrow.set('id', obj.id) + arrow.set('canvasWidth', canvasWidth) + arrow.set('canvasHeight', canvasHeight) + this.addSerialization(arrow) + this.$options.silentAnnnotation = true + canvas.add(arrow) + this.$options.silentAnnnotation = false + return arrow } return path || text || psstroke }, @@ -872,7 +900,6 @@ export const annotationMixin = { * Enable / disable the drawing mode. Differentiate text from path drawing. */ onAnnotateClicked() { - console.log('onAnnotateClicked', this.isDrawing) this.showCanvas() if (this.isDrawing) { this.fabricCanvas.isDrawingMode = false @@ -897,7 +924,6 @@ export const annotationMixin = { }, onDrawShapeClicked() { - console.log('onDrawShapeClicked', this.isDrawingShape) // this.showCanvas() if (this.isDrawingShape) { this.disableCurrentTool() @@ -909,7 +935,6 @@ export const annotationMixin = { this.fabricCanvas.selection = false this.fabricCanvas.skipTargetFind = true } - console.log('addEventListener', this.canvasWrapper) this.canvasWrapper.addEventListener('mousedown', this.startDrawingShape) this.canvasWrapper.addEventListener( 'mousemove', @@ -925,7 +950,6 @@ export const annotationMixin = { const posX = this.getClientX(event) - offsetCanvas.x const posY = this.getClientY(event) - offsetCanvas.y this.shapeStartPos = { x: posX, y: posY } - console.log('startDrawingShape', this.shape, posX, posY, this.shapeColor) if (this.shape === 'rectangle') { this.drawingShape = new fabric.Rect({ left: posX, @@ -955,6 +979,19 @@ export const annotationMixin = { ? 5 : 2 }) + } else if (this.shape === 'arrow') { + this.drawingShape = this.createArrowShape({ + start: [posX, posY], + end: [posX, posY], + stroke: this.shapeColor, + fill: 'rgba(128, 128, 128, 0.25)', + strokeWidth: + this.pencilWidth === 'big' + ? 10 + : this.pencilWidth === 'medium' + ? 5 + : 2 + }) } else { console.error('Unknown shape type:', this.shape) return @@ -962,13 +999,53 @@ export const annotationMixin = { this.fabricCanvas.add(this.drawingShape) }, + createArrowShape(options) { + const { start, end, stroke, strokeWidth } = options + const x1 = start[0] + const y1 = start[1] + const x2 = end[0] + const y2 = end[1] + + // Create custom Arrow object + const arrow = new fabric.Arrow([x1, y1, x2, y2], { + stroke: stroke || this.shapeColor, + strokeWidth: + strokeWidth || + (this.pencilWidth === 'big' + ? 10 + : this.pencilWidth === 'medium' + ? 5 + : 2), + fill: 'transparent', + arrowHeadSize: 15, + arrowHeadWidth: 12 + }) + return arrow + }, + + updateArrowShape(options) { + const { start, end } = options + if (this.drawingShape && this.shape === 'arrow') { + // Update the arrow coordinates using the custom method + this.drawingShape.set({ + x1: start[0], + y1: start[1], + x2: end[0], + y2: end[1] + }) + this.drawingShape.setCoords() + + // Force a re-render + this.fabricCanvas.renderAll() + } + }, + updateDrawingShape(event) { if (!this.drawingShape) return const canvas = this.canvas || this.canvasWrapper const offsetCanvas = canvas.getBoundingClientRect() const posX = this.getClientX(event) - offsetCanvas.x const posY = this.getClientY(event) - offsetCanvas.y - // console.log('updateDrawingShape', this.shape, posX, posY) if (this.shape === 'rectangle') { const deltaX = posX - this.shapeStartPos.x const deltaY = posY - this.shapeStartPos.y @@ -1013,19 +1090,15 @@ export const annotationMixin = { } else { top = this.shapeStartPos.y } - console.log({ - deltaX, - deltaY, - left, - top, - delta, - radius - }) this.drawingShape.set({ left: left, top: top, radius: radius * 0.5 }) + } else if (this.shape === 'arrow') { + const start = [this.shapeStartPos.x, this.shapeStartPos.y] + const end = [posX, posY] + this.updateArrowShape({ start, end }) } else { console.error('Unknown shape type:', this.shape) return @@ -1037,7 +1110,6 @@ export const annotationMixin = { endDrawingShape() { if (!this.drawingShape) return - console.log('endDrawingShape', this.shape, this.drawingShape) this.drawingShape.set({ stroke: this.shapeColor, fill: 'transparent' @@ -1168,6 +1240,11 @@ export const annotationMixin = { const group = movedObject group._objects.forEach(groupObj => { const canvasObj = this.getObjectById(groupObj.id) + if (canvasObj === undefined) { + console.log(groupObj) + throw new Error('Cannot find object in canvas' + groupObj.id) + } + console.log(groupObj.id, canvasObj) this.setObjectData(canvasObj) const targetObj = canvasObj.serialize() const point = new fabric.Point(groupObj.left, groupObj.top) diff --git a/src/components/previews/PreviewPlayer.vue b/src/components/previews/PreviewPlayer.vue index da7fb65299..3db18b38d3 100644 --- a/src/components/previews/PreviewPlayer.vue +++ b/src/components/previews/PreviewPlayer.vue @@ -770,7 +770,8 @@ export default { shapeStartPos: null, SHAPES_ICON_MAPPING: { rectangle: 'rectangle-horizontal', - circle: 'circle' + circle: 'circle', + arrow: 'arrow' }, videoDuration: 0, width: 0, @@ -882,6 +883,10 @@ export default { return this.$refs['canvas-wrapper'] }, + canvas() { + return this.$refs['annotation-canvas'] + }, + canvasComparisonWrapper() { return this.$refs['canvas-comparison-wrapper'] }, @@ -1386,6 +1391,7 @@ export default { const dimensions = this.getDimensions() const width = dimensions.width const height = dimensions.height + console.log('setupFabricCanvas', width, height) // Use markRaw() to avoid reactivity on Fabric Canvas this.fabricCanvas = markRaw( diff --git a/src/components/widgets/ShapePicker.vue b/src/components/widgets/ShapePicker.vue index 1a59ba205c..a2e9041275 100644 --- a/src/components/widgets/ShapePicker.vue +++ b/src/components/widgets/ShapePicker.vue @@ -12,6 +12,7 @@ > +
+