client/app/bundles/course/assessment/submission/components/ScribingView/ScribingCanvas.jsx
/* eslint-disable react/sort-comp */
import { Component } from 'react';
import { fabric } from 'fabric';
import PropTypes from 'prop-types';
import LoadingIndicator from 'lib/components/core/LoadingIndicator';
import {
scribingShapes,
scribingToolColor,
scribingToolLineStyle,
scribingTools,
scribingToolThickness,
} from '../../constants';
import { scribingShape } from '../../propTypes';
const propTypes = {
answerId: PropTypes.number.isRequired,
scribing: scribingShape,
addLayer: PropTypes.func.isRequired,
setCanvasLoaded: PropTypes.func.isRequired,
setCanvasProperties: PropTypes.func.isRequired,
setToolSelected: PropTypes.func.isRequired,
setCurrentStateIndex: PropTypes.func.isRequired,
updateCanvasState: PropTypes.func.isRequired,
setActiveObject: PropTypes.func.isRequired,
setCanvasCursor: PropTypes.func.isRequired,
updateScribingAnswer: PropTypes.func.isRequired,
updateScribingAnswerInLocal: PropTypes.func.isRequired,
resetCanvasDelete: PropTypes.func.isRequired,
resetDisableObjectSelection: PropTypes.func.isRequired,
resetEnableObjectSelection: PropTypes.func.isRequired,
resetCanvasDirty: PropTypes.func.isRequired,
resetCanvasSave: PropTypes.func.isRequired,
resetChangeTool: PropTypes.func.isRequired,
resetUndo: PropTypes.func.isRequired,
resetRedo: PropTypes.func.isRequired,
};
const styles = {
cover: {
position: 'fixed',
top: '0px',
right: '0px',
bottom: '0px',
left: '0px',
},
canvas_div: {
alignItems: 'center',
margin: 'auto',
},
canvas: {
width: '100%',
border: '1px solid black',
},
toolbar: {
marginBottom: '1em',
marginRight: '1em',
},
custom_line: {
display: 'inline-block',
position: 'inherit',
width: '25px',
height: '21px',
marginLeft: '-2px',
transform: 'scale(1.0, 0.2) rotate(90deg) skewX(76deg)',
},
tool: {
position: 'relative',
display: 'inline-block',
paddingRight: '24px',
},
};
export default class ScribingCanvas extends Component {
constructor(props) {
super(props);
this.line = undefined;
this.rect = undefined;
this.ellipse = undefined;
this.viewportLeft = 0;
this.viewportTop = 0;
this.textCreated = false;
this.copiedObjects = [];
this.isScribblesLoaded = false;
}
get currentStateIndex() {
return this.props.scribing.currentStateIndex;
}
get canvasStates() {
return this.props.scribing.canvasStates;
}
componentDidMount() {
const { answerId, scribing } = this.props;
this.initializeCanvas(answerId, scribing.answer.image_url);
}
shouldComponentUpdate(nextProps) {
if (this.canvas) {
this.canvas.isDrawingMode = nextProps.scribing.isDrawingMode;
this.canvas.freeDrawingBrush.color =
nextProps.scribing.colors[scribingToolColor.DRAW];
this.canvas.freeDrawingBrush.width =
nextProps.scribing.thickness[scribingToolThickness.DRAW];
this.canvas.defaultCursor = nextProps.scribing.cursor;
this.currentCursor = nextProps.scribing.cursor;
this.canvas.zoomToPoint(
{
x: this.canvas.height / 2,
y: this.canvas.width / 2,
},
nextProps.scribing.canvasZoom,
);
this.canvas.fire('mouse:move', { isForced: true });
if (nextProps.scribing.isEnableObjectSelection) {
// Objects are selectable in Type tool, dont have to enableObjectSelection again
const isActiveObjectText =
this.canvas.getActiveObject() &&
this.canvas.getActiveObject().type === 'i-text';
if (isActiveObjectText) {
this.canvas.getActiveObject().exitEditing();
} else {
this.enableObjectSelection();
}
this.props.resetEnableObjectSelection(this.props.answerId);
}
// Discard prior active object/group when using other tools
const isNonDrawingTool =
nextProps.scribing.selectedTool !== scribingTools.TYPE &&
nextProps.scribing.selectedTool !== scribingTools.DRAW &&
nextProps.scribing.selectedTool !== scribingTools.LINE &&
nextProps.scribing.selectedTool !== scribingTools.SHAPE;
if (nextProps.scribing.isChangeTool) {
if (isNonDrawingTool) {
this.canvas.discardActiveObject();
}
this.canvas.renderAll();
this.props.resetChangeTool(this.props.answerId);
}
if (nextProps.scribing.isDisableObjectSelection) {
this.disableObjectSelection();
this.props.resetDisableObjectSelection(this.props.answerId);
}
if (nextProps.scribing.isCanvasDirty) {
this.canvas.renderAll();
this.props.resetCanvasDirty(this.props.answerId);
}
if (nextProps.scribing.isCanvasSave) {
this.saveScribbles();
this.props.resetCanvasSave(this.props.answerId);
}
if (nextProps.scribing.isUndo) {
this.undo();
this.props.resetUndo(this.props.answerId);
}
if (nextProps.scribing.isRedo) {
this.redo();
this.props.resetRedo(this.props.answerId);
}
if (nextProps.scribing.isDelete) {
this.deleteActiveObjects();
this.props.resetCanvasDelete(this.props.answerId);
}
}
// Render canvas only at the beginning
return !this.props.scribing.isCanvasLoaded;
}
deleteActiveObjects = () => {
const activeObjects = this.canvas.getActiveObjects();
this.canvas.discardActiveObject();
const lastObjectIndex = Math.max(activeObjects.length - 1, 0);
this.isScribblesLoaded = false;
activeObjects.forEach((object, index) => {
if (index === lastObjectIndex) this.isScribblesLoaded = true;
this.canvas.remove(object);
});
this.isScribblesLoaded = true;
};
onKeyDown = (event) => {
if (!this.canvas) return;
const activeObject = this.canvas.getActiveObject();
const activeObjects = this.canvas.getActiveObjects();
switch (event.keyCode) {
case 8: // Backspace key
case 46: {
// Delete key
this.deleteActiveObjects();
break;
}
case 67: {
// Ctrl+C
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
this.copiedObjects = [];
activeObjects.forEach((obj) => this.copiedObjects.push(obj));
this.copyLeft = activeObject.left;
this.copyTop = activeObject.top;
}
break;
}
case 86: {
// Ctrl+V
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
this.canvas.discardActiveObject();
const newObjects = [];
let newObj = {};
// Don't wrap single object in group,
// in case it's i-text and we want it to be editable at first tap
if (this.copiedObjects.length === 1) {
const obj = this.copiedObjects[0];
if (obj.type === 'i-text') {
newObj = this.cloneText(obj);
} else {
obj.clone((c) => {
newObj = c;
});
}
this.setCopiedCanvasObjectPosition(newObj);
this.canvas.add(newObj);
this.canvas.setActiveObject(newObj);
this.canvas.renderAll();
} else {
// Cloning a group of objects
this.copiedObjects.forEach((obj) => {
if (obj.type === 'i-text') {
newObj = this.cloneText(obj);
} else {
obj.clone((c) => {
newObj = c;
});
}
newObj.setCoords();
this.canvas.add(newObj);
newObjects.push(newObj);
});
const selection = new fabric.ActiveSelection(newObjects, {
canvas: this.canvas,
});
this.setCopiedCanvasObjectPosition(selection);
this.canvas.setActiveObject(selection);
this.canvas.renderAll();
}
}
break;
}
case 90: {
// Ctrl-Z
if (event.ctrlKey || event.metaKey) {
if (event.shiftKey) {
this.redo();
} else {
this.undo();
}
}
break;
}
default:
}
};
// Canvas Event Handlers
onMouseDownCanvas = (options) => {
this.mouseCanvasDragStartPoint = this.getCanvasPoint(options.e);
// To facilitate moving
this.mouseDownFlag = true;
this.viewportLeft = this.canvas.viewportTransform[4];
this.viewportTop = this.canvas.viewportTransform[5];
this.mouseStartPoint = this.getMousePoint(options.e);
this.isOverActiveObject =
options.target !== null &&
options.target === this.canvas.getActiveObject();
const getStrokeDashArray = (toolType) => {
switch (this.props.scribing.lineStyles[toolType]) {
case 'dotted': {
return [1, 3];
}
case 'dashed': {
return [10, 5];
}
case 'solid':
default: {
return [];
}
}
};
if (this.mouseCanvasDragStartPoint) {
if (this.props.scribing.selectedTool === scribingTools.SELECT) {
this.canvas.selectionBorderColor = 'gray';
this.canvas.selectionDashArray = [1, 3];
} else {
this.canvas.selectionBorderColor = 'transparent';
this.canvas.selectionDashArray = [];
}
if (
this.props.scribing.selectedTool === scribingTools.LINE &&
!this.isOverActiveObject
) {
// Make previous line unselectable if it exists
if (this.line && this.line.type === 'line') {
this.line.selectable = false;
}
const strokeDashArray = getStrokeDashArray(scribingToolLineStyle.LINE);
this.line = new fabric.Line(
[
this.mouseCanvasDragStartPoint.x,
this.mouseCanvasDragStartPoint.y,
this.mouseCanvasDragStartPoint.x,
this.mouseCanvasDragStartPoint.y,
],
{
stroke: `${this.props.scribing.colors[scribingToolColor.LINE]}`,
strokeWidth:
this.props.scribing.thickness[scribingToolThickness.LINE],
strokeDashArray,
selectable: true,
},
);
this.canvas.add(this.line);
this.canvas.setActiveObject(this.line);
this.canvas.renderAll();
} else if (
this.props.scribing.selectedTool === scribingTools.SHAPE &&
!this.isOverActiveObject
) {
const strokeDashArray = getStrokeDashArray(
scribingToolLineStyle.SHAPE_BORDER,
);
switch (this.props.scribing.selectedShape) {
case scribingShapes.RECT: {
// Make previous rect unselectable if it exists
if (this.rect && this.rect.type === 'rect') {
this.rect.selectable = false;
}
this.rect = new fabric.Rect({
left: this.mouseCanvasDragStartPoint.x,
top: this.mouseCanvasDragStartPoint.y,
stroke: `${
this.props.scribing.colors[scribingToolColor.SHAPE_BORDER]
}`,
strokeWidth:
this.props.scribing.thickness[
scribingToolThickness.SHAPE_BORDER
],
strokeDashArray,
fill: `${
this.props.scribing.colors[scribingToolColor.SHAPE_FILL]
}`,
width: 1,
height: 1,
selectable: true,
});
this.canvas.add(this.rect);
this.canvas.setActiveObject(this.rect);
this.canvas.renderAll();
break;
}
case scribingShapes.ELLIPSE: {
// Make previous line unselectable if it exists
if (this.ellipse && this.ellipse.type === 'ellipse') {
this.ellipse.selectable = false;
}
this.ellipse = new fabric.Ellipse({
left: this.mouseCanvasDragStartPoint.x,
top: this.mouseCanvasDragStartPoint.y,
stroke: `${
this.props.scribing.colors[scribingToolColor.SHAPE_BORDER]
}`,
strokeWidth:
this.props.scribing.thickness[
scribingToolThickness.SHAPE_BORDER
],
strokeDashArray,
fill: `${
this.props.scribing.colors[scribingToolColor.SHAPE_FILL]
}`,
rx: 1,
ry: 1,
selectable: true,
});
this.canvas.add(this.ellipse);
this.canvas.setActiveObject(this.ellipse);
this.canvas.renderAll();
break;
}
default: {
break;
}
}
}
}
if (
this.props.scribing.selectedTool !== scribingTools.TYPE &&
this.textCreated
) {
this.textCreated = false;
// Only allow one i-text to be created per selection of TEXT mode
// Second click in non-text area will exit to SELECT mode
} else if (
!this.isOverText &&
this.props.scribing.selectedTool === scribingTools.TYPE &&
!this.textCreated
) {
const text = new fabric.IText('', {
fontFamily: this.props.scribing.fontFamily,
fontSize: this.props.scribing.fontSize,
fill: this.props.scribing.colors[scribingToolColor.TYPE],
left: this.mouseCanvasDragStartPoint.x,
top: this.mouseCanvasDragStartPoint.y,
padding: 5,
});
// Don't allow scaling of text object
text.setControlsVisibility({
bl: false,
br: false,
mb: false,
ml: false,
mr: false,
mt: false,
tl: false,
tr: false,
});
this.canvas.add(text);
this.canvas.setActiveObject(text);
text.enterEditing();
this.canvas.renderAll();
this.textCreated = true;
}
};
onMouseMoveCanvas = (options) => {
const dragPointer = this.getCanvasPoint(options.e);
// Do moving action
const tryMove = (left, top) => {
// limit moving
let finalLeft = Math.min(left, 0);
finalLeft = Math.max(
finalLeft,
(this.canvas.getZoom() - 1) * this.canvas.getWidth() * -1,
);
let finalTop = Math.min(top, 0);
finalTop = Math.max(
finalTop,
(this.canvas.getZoom() - 1) * this.canvas.getHeight() * -1,
);
// apply calculated move transforms
this.canvas.viewportTransform[4] = finalLeft;
this.canvas.viewportTransform[5] = finalTop;
this.canvas.renderAll();
};
if (this.mouseDownFlag) {
if (
dragPointer &&
this.props.scribing.selectedTool === scribingTools.LINE &&
!this.isOverActiveObject
) {
this.line.set({ x2: dragPointer.x, y2: dragPointer.y });
this.canvas.renderAll();
} else if (
dragPointer &&
this.props.scribing.selectedTool === scribingTools.SHAPE &&
!this.isOverActiveObject
) {
switch (this.props.scribing.selectedShape) {
case scribingShapes.RECT: {
const dragProps = this.generateMouseDragProperties(
this.mouseCanvasDragStartPoint,
dragPointer,
);
this.rect.set({
left: dragProps.left,
top: dragProps.top,
width: dragProps.width,
height: dragProps.height,
});
this.canvas.renderAll();
break;
}
case scribingShapes.ELLIPSE: {
const dragProps = this.generateMouseDragProperties(
this.mouseCanvasDragStartPoint,
dragPointer,
);
this.ellipse.set({
left: dragProps.left,
top: dragProps.top,
rx: dragProps.width / 2,
ry: dragProps.height / 2,
});
this.canvas.renderAll();
break;
}
default: {
break;
}
}
} else if (this.props.scribing.selectedTool === scribingTools.MOVE) {
const mouseCurrentPoint = this.getMousePoint(options.e);
const deltaLeft = mouseCurrentPoint.x - this.mouseStartPoint.x;
const deltaTop = mouseCurrentPoint.y - this.mouseStartPoint.y;
const newLeft = this.viewportLeft + deltaLeft;
const newTop = this.viewportTop + deltaTop;
tryMove(newLeft, newTop);
}
} else if (options.isForced) {
// Facilitates zooming out
tryMove(
this.canvas.viewportTransform[4],
this.canvas.viewportTransform[5],
);
}
};
onMouseOut = () => {
this.isOverText = false;
};
onMouseOver = (options) => {
if (options.target && options.target.type === 'i-text') {
this.isOverText = true;
}
};
onMouseUpCanvas = () => {
this.mouseDownFlag = false;
switch (this.props.scribing.selectedTool) {
case scribingTools.DRAW: {
this.saveScribbles();
break;
}
case scribingTools.LINE: {
if (this.line.height + this.line.width < 10) {
this.canvas.remove(this.line);
this.canvas.renderAll();
} else {
this.saveScribbles();
}
break;
}
case scribingTools.SHAPE: {
if (this.props.scribing.selectedShape === scribingShapes.RECT) {
if (this.rect.height + this.rect.width < 10) {
this.canvas.remove(this.rect);
this.canvas.renderAll();
} else {
this.saveScribbles();
}
} else if (
this.props.scribing.selectedShape === scribingShapes.ELLIPSE
) {
if (this.ellipse.height + this.ellipse.width < 10) {
this.canvas.remove(this.ellipse);
this.canvas.renderAll();
} else {
this.saveScribbles();
}
}
break;
}
default:
}
};
onObjectMovingCanvas = ({ target }) => {
const object = target;
const canvas = this.canvas;
const width = object.getBoundingRect().width;
const height = object.getBoundingRect().height;
if (width > canvas.width || height > canvas.height) return;
// Limit movement of objects to only within canvas
const canvasRight = canvas.width - width;
const canvasBottom = canvas.height - height;
object.top = Math.min(Math.max(0, object.top), canvasBottom);
object.left = Math.min(Math.max(0, object.left), canvasRight);
object.setCoords();
};
onObjectSelected = (options) => {
if (options.target) {
this.props.setActiveObject(this.props.answerId, options.target);
}
};
onSelectionCleared = () => {
this.props.setActiveObject(this.props.answerId, undefined);
};
onTextChanged = (options) => {
if (options.target.text.trim() === '') {
this.canvas.remove(options.target);
}
this.textCreated = false;
this.saveScribbles();
this.props.setToolSelected(this.props.answerId, scribingTools.SELECT);
this.props.setCanvasCursor(this.props.answerId, 'default');
};
getCanvasPoint(event) {
if (!event) return undefined;
const pointer = this.canvas.getPointer(event);
return {
x: pointer.x,
y: pointer.y,
};
}
/**
* @param {string} json: JSON string with 'objects' key containing array of scribbles
* @return {array} array of Fabric objects
*/
getFabricObjectsFromJson = (json) => {
if (!json) return null;
const objects = JSON.parse(json).objects;
const userScribbles = [];
// Parse JSON to Fabric.js objects
for (let i = 0; i < objects.length; i++) {
if (objects[i].type !== 'group') {
const klass = fabric.util.getKlass(objects[i].type);
klass.fromObject(objects[i], (obj) => {
this.denormaliseScribble(obj);
userScribbles.push(obj);
});
}
}
return userScribbles;
};
// eslint-disable-next-line class-methods-use-this
getMousePoint = (event) => ({
x: event.clientX,
y: event.clientY,
});
get scribblesAsJson() {
// Remove non-user scribings in canvas
this.props.scribing.layers.forEach((layer) => {
if (layer.creator_id !== this.props.scribing.answer.user_id) {
layer.showLayer(false);
}
});
// Only save rescaled user scribings
const objects = this.canvas.getObjects();
objects.forEach((obj) => {
this.normaliseScribble(obj);
});
const json = JSON.stringify(objects);
// Scale back user scribings
objects.forEach((obj) => {
this.denormaliseScribble(obj);
});
// Add back non-user scribings according canvas state
this.props.scribing.layers.forEach((layer) =>
layer.showLayer(layer.isDisplayed),
);
return `{"objects": ${json}}`;
}
setCopiedCanvasObjectPosition(obj) {
// Shift copied object to the left if there's space
this.copyLeft =
this.copyLeft + obj.width > this.canvas.width
? this.copyLeft
: this.copyLeft + 10;
obj.left = this.copyLeft; // eslint-disable-line no-param-reassign
// Shift copied object down if there's space
this.copyTop =
this.copyTop + obj.height > this.canvas.height
? this.copyTop
: this.copyTop + 10;
obj.top = this.copyTop; // eslint-disable-line no-param-reassign
obj.setCoords();
}
/**
* Draws the given `scribbles` on the canvas
* @param scribbles Scribbles as a fabric object
* @param scribbleCallback (optional) Function to be called for each
* `fabric.canvas.add` on scribble
*/
rehydrateCanvas = (scribbles, scribbleCallback) => {
this.isScribblesLoaded = false;
this.canvas.clear();
this.canvas.setBackground();
this.props.scribing.layers.forEach((layer) =>
this.canvas.add(layer.scribbleGroup),
);
scribbles.forEach((scribble) => {
scribbleCallback?.(scribble);
this.canvas.add(scribble);
});
this.canvas.renderAll();
this.isScribblesLoaded = true;
};
setCanvasStateAndUpdateAnswer = (stateIndex) => {
const state = this.canvasStates[stateIndex];
const scribbles = this.getFabricObjectsFromJson(state);
if (!scribbles)
throw new Error(`trying to set canvas state to ${scribbles}`);
this.rehydrateCanvas(scribbles);
this.props.setCurrentStateIndex(this.props.answerId, stateIndex);
this.updateAnswer(state);
};
// Utility Helpers
// eslint-disable-next-line class-methods-use-this
cloneText = (obj) => {
const newObj = new fabric.IText(obj.text, {
left: obj.left,
top: obj.top,
fontFamily: obj.fontFamily,
fontSize: obj.fontSize,
fill: obj.fill,
padding: 5,
});
newObj.setControlsVisibility({
bl: false,
br: false,
mb: false,
ml: false,
mr: false,
mt: false,
tl: false,
tr: false,
});
return newObj;
};
denormaliseScribble(scribble) {
return this.normaliseScribble(scribble, true);
}
disableObjectSelection() {
this.canvas.forEachObject((object) => {
object.selectable = false; // eslint-disable-line no-param-reassign
object.hoverCursor = this.currentCursor; // eslint-disable-line no-param-reassign
});
}
/**
* Clears the selection-disabled scribbles
* and reloads them to enable selection again
*/
enableObjectSelection() {
const state = this.canvasStates[this.currentStateIndex];
const scribbles = this.getFabricObjectsFromJson(state);
this.rehydrateCanvas(scribbles, (scribble) => {
if (scribble.type === 'i-text') {
scribble.setControlsVisibility({
bl: false,
br: false,
mb: false,
ml: false,
mr: false,
mt: false,
tl: false,
tr: false,
});
}
});
}
// Generates the left, top, width and height of the drag
// eslint-disable-next-line class-methods-use-this
generateMouseDragProperties = (point1, point2) => ({
left: point1.x < point2.x ? point1.x : point2.x,
top: point1.y < point2.y ? point1.y : point2.y,
width: Math.abs(point1.x - point2.x),
height: Math.abs(point1.y - point2.y),
});
initializeCanvas(answerId, imageUrl) {
this.image = new Image();
this.image.src = imageUrl;
this.image.onload = () => {
// Get the calculated width of canvas, 800 is min width for scribing toolbar
const element = document.getElementById(`canvas-${answerId}`);
const maxWidth = Math.max(element.getBoundingClientRect().width, 800);
this.width = Math.min(this.image.width, maxWidth);
this.scale = Math.min(this.width / this.image.width, 1);
this.height = this.scale * this.image.height;
this.canvas = new fabric.Canvas(`canvas-${answerId}`, {
width: this.width,
height: this.height,
preserveObjectStacking: true,
renderOnAddRemove: false,
objectCaching: false,
statefullCache: false,
noScaleCache: true,
needsItsOwnCache: false,
selectionColor: 'transparent',
backgroundColor: 'white',
});
this.props.setCanvasProperties(
this.props.answerId,
this.width,
this.height,
maxWidth,
);
const fabricImage = new fabric.Image(this.image, {
opacity: 1,
scaleX: this.scale,
scaleY: this.scale,
});
this.canvas.setBackground = () =>
this.canvas.setBackgroundImage(
fabricImage,
this.canvas.renderAll.bind(this.canvas),
);
const canvasElem = document.getElementById(
`canvas-container-${answerId}`,
);
canvasElem.tabIndex = 1000;
// Minimise reflows
canvasElem.setAttribute(
'style',
`background: lightgrey;
max-width: ${maxWidth}px;
margin: 0px;
outline: none;`,
);
canvasElem.addEventListener('keydown', this.onKeyDown, false);
const canvasContainerElem =
canvasElem.getElementsByClassName('canvas-container')[0];
canvasContainerElem.style.margin = '0 auto';
this.initializeScribblesAndBackground();
this.canvas.on('mouse:down', this.onMouseDownCanvas);
this.canvas.on('mouse:move', this.onMouseMoveCanvas);
this.canvas.on('mouse:up', this.onMouseUpCanvas);
this.canvas.on('mouse:over', this.onMouseOver);
this.canvas.on('mouse:out', this.onMouseOut);
this.canvas.on('object:moving', this.onObjectMovingCanvas);
this.canvas.on('object:modified', this.saveScribbles);
this.canvas.on('object:removed', this.saveScribbles);
this.canvas.on('selection:created', this.onObjectSelected);
this.canvas.on('selection:updated', this.onObjectSelected);
this.canvas.on('selection:cleared', this.onSelectionCleared);
this.canvas.on('text:editing:exited', this.onTextChanged);
this.scaleCanvas();
this.props.setCanvasLoaded(this.props.answerId, true);
};
}
initializeScribblesAndBackground = () => {
const { scribbles } = this.props.scribing.answer;
const { layers } = this.props.scribing;
const userId = this.props.scribing.answer.user_id;
this.isScribblesLoaded = false;
let userScribble = [];
layers.forEach((layer) => this.canvas.add(layer.scribbleGroup));
if (scribbles) {
scribbles.forEach((scribble) => {
const fabricObjs = this.getFabricObjectsFromJson(scribble.content);
// Create layer for each user's scribble
// Scribbles in layers have selection disabled
if (scribble.creator_id !== userId) {
const scribbleGroup = new fabric.Group(fabricObjs);
scribbleGroup.selectable = false;
const showLayer = (isShown) => {
scribbleGroup._objects.forEach((obj) => {
obj.setVisible?.(isShown);
});
this.canvas.renderAll();
};
// Populate layers list
const newScribble = {
...scribble,
isDisplayed: true,
showLayer,
scribbleGroup,
};
this.props.addLayer(this.props.answerId, newScribble);
this.canvas.add(scribbleGroup);
} else if (scribble.creator_id === userId) {
// Add other user's layers first to avoid blocking of user's layer
userScribble = fabricObjs;
}
});
// Layer for current user's scribble
// Enables scribble selection
userScribble.forEach((obj) => {
// Don't allow scaling of text object
if (obj.type === 'i-text') {
obj.setControlsVisibility({
bl: false,
br: false,
mb: false,
ml: false,
mr: false,
mt: false,
tl: false,
tr: false,
});
}
this.canvas.add(obj);
});
}
this.canvas.setBackground();
this.canvas.renderAll();
this.isScribblesLoaded = true;
this.saveScribbles(); // Add initial state as index 0 is states history
};
// Helpers
/**
* Scales/unscales the given scribbles by a standard number.
* Legacy method needed to support migrated v1 scribing questions.
*/
normaliseScribble(scribble, isDenormalise) {
const STANDARD = 1000;
let factor;
if (isDenormalise) {
factor = this.canvas.getWidth() / STANDARD;
} else {
factor = STANDARD / this.canvas.getWidth();
}
scribble.set({
scaleX: scribble.scaleX * factor,
scaleY: scribble.scaleY * factor,
left: scribble.left * factor,
top: scribble.top * factor,
});
}
updateAnswer = (state) => {
const answerId = this.props.answerId;
const answerActableId = this.props.scribing.answer.answer_id;
this.props.updateScribingAnswerInLocal(answerId, state);
this.props.updateScribingAnswer(answerId, answerActableId, state);
};
saveScribbles = () => {
if (!this.isScribblesLoaded) return null;
return new Promise((resolve) => {
// See https://github.com/Coursemology/coursemology2/pull/4957 to learn
// discarding and resetting active objects matters
const activeObjects = this.canvas.getActiveObjects();
this.canvas.discardActiveObject();
const answerId = this.props.answerId;
const state = this.scribblesAsJson;
this.updateAnswer(state);
this.props.updateCanvasState(answerId, state);
if (activeObjects.length > 1)
this.canvas.setActiveObject(
new fabric.ActiveSelection(activeObjects, { canvas: this.canvas }),
);
resolve();
});
};
/**
* Adjusting canvas height after canvas initialization
* helps to scale/move scribbles accordingly
*/
scaleCanvas() {
this.canvas.setWidth(this.width);
this.canvas.setHeight(this.height);
this.canvas.renderAll();
}
undo = () => {
if (this.currentStateIndex <= 0) return;
this.setCanvasStateAndUpdateAnswer(this.currentStateIndex - 1);
};
redo = () => {
const lastStateIndex = this.canvasStates.length - 1;
const currentStateIndex = this.currentStateIndex;
const hasNextStates = currentStateIndex < lastStateIndex;
const hasStates = this.canvasStates.length > 1;
if (!hasNextStates || !hasStates) return;
this.setCanvasStateAndUpdateAnswer(this.currentStateIndex + 1);
};
render() {
const answerId = this.props.answerId;
if (!answerId) return null;
const isCanvasLoaded = this.props.scribing.isCanvasLoaded;
return (
<div id={`canvas-container-${answerId}`} style={styles.canvas_div}>
{!isCanvasLoaded ? <LoadingIndicator /> : null}
<canvas
data-testid={`canvas-${answerId}`}
id={`canvas-${answerId}`}
style={styles.canvas}
/>
</div>
);
}
}
ScribingCanvas.propTypes = propTypes;