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 a9b1442def..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) { @@ -221,6 +225,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 @@ -714,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 }, @@ -754,6 +812,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 +852,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 @@ -844,6 +923,229 @@ export const annotationMixin = { } }, + onDrawShapeClicked() { + // 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 + } + 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 } + 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, + strokeWidth: + this.pencilWidth === 'big' + ? 10 + : this.pencilWidth === 'medium' + ? 5 + : 2 + }) + } 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, + strokeWidth: + this.pencilWidth === 'big' + ? 10 + : this.pencilWidth === 'medium' + ? 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 + } + 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 + 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 + } + 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 + } + // Update the shape's coordinates and render the canvas + this.drawingShape.setCoords() + this.fabricCanvas.renderAll() + }, + + endDrawingShape() { + if (!this.drawingShape) return + this.drawingShape.set({ + stroke: this.shapeColor, + 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. */ @@ -938,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 b852b3b034..3db18b38d3 100644 --- a/src/components/previews/PreviewPlayer.vue +++ b/src/components/previews/PreviewPlayer.vue @@ -312,6 +312,39 @@ 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..a2e9041275 --- /dev/null +++ b/src/components/widgets/ShapePicker.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/src/lib/arrowshape.js b/src/lib/arrowshape.js new file mode 100644 index 0000000000..ffdf1e38c2 --- /dev/null +++ b/src/lib/arrowshape.js @@ -0,0 +1,390 @@ +import { fabric } from 'fabric' + +/** + * Custom Fabric.js Arrow object + * Creates an arrow with a line body and triangular arrowhead + */ +export class Arrow extends fabric.Line { + constructor(points, options = {}) { + // Set default values + super(points, options) + + // Apply options + this.setOptions(options) + // this._setWidthHeight() + // this._setLeftTop() + + // Set the type + this.type = 'arrow' + + // Setup custom controls + this._setupControls() + + // Store the initial center position for relative calculations + // this._lastLeft = this.left + // this._lastTop = this.top + + // this.on("mousedown:before", this._onMouseDown.bind(this)) + this.on('moving', e => { + console.log(e) + if (e.transform.action !== 'drag') { + return + } + console.log('Dragging from to', this.left, e.transform.ex) + const diffX = e.transform.ex - this.left + const diffY = e.transform.ey - this.top + console.log(' Diff', diffX, diffY) + this.set({ + x1: this.x1 + e.e.movementX, + y1: this.y1 + e.e.movementY, + x2: this.x2 + e.e.movementX, + y2: this.y2 + e.e.movementY + }) + }) + } + + _onMouseDown(event) { + console.log('Mouse down on arrow:', this, event) + } + + /** + * Setup custom controls for start and end points + */ + _setupControls() { + const controls = (this.controls = {}) + + // Start point control + controls.start = new fabric.Control({ + x: 0, + y: 0, + offsetX: 0, + offsetY: 0, + cursorStyle: 'pointer', + mouseUpHandler: this._startPointHandler.bind(this), + actionHandler: this._startPointHandler.bind(this), + actionName: 'startPoint', + render: this._renderStartControl.bind(this), + positionHandler: this._startControlPosition.bind(this) + }) + + // End point control + controls.end = new fabric.Control({ + x: 0, + y: 0, + offsetX: 0, + offsetY: 0, + cursorStyle: 'pointer', + mouseUpHandler: this._endPointHandler.bind(this), + actionHandler: this._endPointHandler.bind(this), + actionName: 'endPoint', + render: this._renderEndControl.bind(this), + positionHandler: this._endControlPosition.bind(this) + }) + + // Hide default controls except for rotation + this.setControlsVisibility({ + tl: false, + tr: false, + br: false, + bl: false, + ml: false, + mr: false, + mb: false, + mt: false, + mtr: false // Keep rotation control + }) + } + + /** + * Position handler for start control + */ + _startControlPosition(dim, finalMatrix, fabricObject) { + // Calculate start point relative to object center + + return new fabric.Point(this.x1, this.y1) + } + + /** + * Position handler for end control + */ + _endControlPosition(dim, finalMatrix, fabricObject) { + // Calculate end point relative to object center + + // return new fabric.Point(this.x2, this.y2) + const x = this.x1 > this.x2 ? this.left : this.left + this.width + const y = this.y1 > this.y2 ? this.top : this.top + this.height + return new fabric.Point(x, y) + } + + /** + * Render start point control + */ + _renderStartControl(ctx, left, top, styleOverride, fabricObject) { + // console.log("start render", left, top) + // console.log(ctx) + const size = 8 + ctx.save() + ctx.fillStyle = '#4CAF50' // Green for start + ctx.strokeStyle = '#fff' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(left, top, size / 2, 0, 2 * Math.PI) + ctx.fill() + ctx.stroke() + ctx.restore() + } + + /** + * Render end point control + */ + _renderEndControl(ctx, left, top, styleOverride, fabricObject) { + const size = 8 + ctx.save() + ctx.fillStyle = '#f44336' // Red for end + ctx.strokeStyle = '#fff' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(left, top, size / 2, 0, 2 * Math.PI) + ctx.fill() + ctx.stroke() + ctx.restore() + } + + /** + * Handle start point dragging + */ + _startPointHandler(eventData, transformData, x, y) { + const pointer = this.canvas.getPointer(eventData.e) + // console.log("start point handler", pointer) + // Update x1, y1 to the pointer position + // console.log("x1, y1", this.x1, this.y1) + // console.log("left, top", this.left, this.top) + // console.log("width, height", this.width, this.height) + this.set({ + x1: pointer.x, + y1: pointer.y + }) + + // this._updateFromCoordinates() + this.setCoords() + this.canvas.renderAll() + + return true + } + + /** + * Handle end point dragging + */ + _endPointHandler(eventData, transformData, x, y) { + const pointer = this.canvas.getPointer(eventData.e) + + // Update x2, y2 to the pointer position + this.set({ + x2: pointer.x, + y2: pointer.y + }) + + // this._updateFromCoordinates() + this.setCoords() + this.canvas.renderAll() + + return true + } + + /** + * Update object properties when coordinates change + */ + _updateFromCoordinates() { + this._setWidthHeight() + this._setLeftTop() + + // Update object position to center of bounding box + const centerX = (this.x1 + this.x2) / 2 + const centerY = (this.y1 + this.y2) / 2 + // this.set({ + // left: centerX, + // top: centerY + // }) + + // Update the last known position + this._lastLeft = centerX + this._lastTop = centerY + } + + /** + * Override the setCoords method to update absolute coordinates + */ + setCoords() { + // this._updateAbsoluteCoordinates() + return super.setCoords() + } + + /** + * Calculate and set width/height based on coordinates + */ + // _setWidthHeight() { + // const width = Math.abs(this.x2 - this.x1) + // const height = Math.abs(this.y2 - this.y1) + // this.set({ + // width: width || 1 + this.strokeWidth, + // height: height || 1 + this.strokeWidth + // }) + // } + + _setLeftTop() { + const left = (this.x1 + this.x2) / 2 + const top = (this.y1 + this.y2) / 2 + this.set({ + left: left, + top: top + }) + } + + /** + * Render the arrow on the canvas + * @param {CanvasRenderingContext2D} ctx - Canvas context + */ + _render(ctx) { + // The arrow tail will be drawn by the Line super class + super._render(ctx) + // Calculate relative coordinates (relative to object's center) + const xDiff = this.x2 - this.x1 + const yDiff = this.y2 - this.y1 + + // Calculate arrow body coordinates relative to center + const x2 = -this.width / 2 + (this.x2 - Math.min(this.x1, this.x2)) + const y2 = -this.height / 2 + (this.y2 - Math.min(this.y1, this.y2)) + + // Calculate arrow head + const angle = Math.atan2(yDiff, xDiff) + const arrowLength = this.arrowHeadSize + const arrowAngle = Math.PI / 6 // 30 degrees + + // Arrow head coordinates - create a proper triangular tip + const arrowX1 = x2 - arrowLength * Math.cos(angle - arrowAngle) + const arrowY1 = y2 - arrowLength * Math.sin(angle - arrowAngle) + const arrowX2 = x2 - arrowLength * Math.cos(angle + arrowAngle) + const arrowY2 = y2 - arrowLength * Math.sin(angle + arrowAngle) + + ctx.beginPath() + + // Draw the arrowhead as a closed triangular path + ctx.moveTo(arrowX1, arrowY1) + ctx.lineTo(x2, y2) // tip of the arrow + ctx.lineTo(arrowX2, arrowY2) + ctx.closePath() + + // Apply stroke styles and stroke + this._setStrokeStyles(ctx, this) + ctx.stroke() + + // Optionally fill the arrowhead for a solid tip + if (this.fill && this.fill !== 'transparent') { + ctx.fillStyle = this.fill + ctx.fill() + } + } + + /** + * Convert object to plain object for serialization + * @param {Array} propertiesToInclude - Properties to include in serialization + * @returns {Object} Plain object representation + */ + toObject(propertiesToInclude = []) { + return fabric.util.object.extend(super.toObject(propertiesToInclude), { + x1: this.x1, + y1: this.y1, + x2: this.x2, + y2: this.y2, + arrowHeadSize: this.arrowHeadSize, + arrowHeadWidth: this.arrowHeadWidth, + _lastLeft: this._lastLeft, + _lastTop: this._lastTop + }) + } + + /** + * Update arrow coordinates + * @param {number} x1 - Start X coordinate + * @param {number} y1 - Start Y coordinate + * @param {number} x2 - End X coordinate + * @param {number} y2 - End Y coordinate + */ + updateCoordinates(x1, y1, x2, y2) { + this.set({ + x1: x1, + y1: y1, + x2: x2, + y2: y2 + }) + this._updateFromCoordinates() + this.setCoords() + } + + /** + * Returns svg representation of an instance + * @return {string} SVG string representation + */ + _toSVG() { + const xDiff = this.x2 - this.x1 + const yDiff = this.y2 - this.y1 + + // Calculate relative coordinates + const x1 = -this.width / 2 + (this.x1 - Math.min(this.x1, this.x2)) + const y1 = -this.height / 2 + (this.y1 - Math.min(this.y1, this.y2)) + const x2 = -this.width / 2 + (this.x2 - Math.min(this.x1, this.x2)) + const y2 = -this.height / 2 + (this.y2 - Math.min(this.y1, this.y2)) + + // Arrow head calculations + const angle = Math.atan2(yDiff, xDiff) + const arrowLength = this.arrowHeadSize + const arrowAngle = Math.PI / 6 + const arrowX1 = x2 - arrowLength * Math.cos(angle - arrowAngle) + const arrowY1 = y2 - arrowLength * Math.sin(angle - arrowAngle) + const arrowX2 = x2 - arrowLength * Math.cos(angle + arrowAngle) + const arrowY2 = y2 - arrowLength * Math.sin(angle + arrowAngle) + + // Calculate where the arrow body should end + const bodyEndOffset = arrowLength * 0.7 + const bodyEndX = x2 - bodyEndOffset * Math.cos(angle) + const bodyEndY = y2 - bodyEndOffset * Math.sin(angle) + + const fillAttr = + this.fill && this.fill !== 'transparent' + ? `fill="${this.fill}"` + : 'fill="none"' + + return [ + '', + ``, + ``, + '' + ].join('') + } + + /** + * Static method to create Arrow from object (for deserialization) + * @param {Object} object - Plain object representation + * @param {Function} callback - Callback function + */ + static fromObject(object, callback) { + const arrow = new Arrow(object) + if (callback) callback(arrow) + return arrow + } +} + +// Register the Arrow class with Fabric.js for backward compatibility +fabric.Arrow = Arrow + +/** + * Register the Arrow shape with Fabric.js + * This function should be called to ensure the Arrow class is available in Fabric.js + */ +export function registerArrowFabricShape() { + if (!fabric.Arrow) { + fabric.Arrow = Arrow + } +} + +export default Arrow