Create pointer event handler for pencil, shape and text.

This commit is contained in:
Jonathan Schilling 2020-09-22 12:16:31 +00:00 committed by Jonathan S
parent 52637c59e0
commit a19451a2c4
3 changed files with 1445 additions and 0 deletions

View File

@ -0,0 +1,377 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Logger from '/imports/startup/client/logger';
const ANNOTATION_CONFIG = Meteor.settings.public.whiteboard.annotations;
const DRAW_START = ANNOTATION_CONFIG.status.start;
const DRAW_UPDATE = ANNOTATION_CONFIG.status.update;
const DRAW_END = ANNOTATION_CONFIG.status.end;
// maximum value of z-index to prevent other things from overlapping
const MAX_Z_INDEX = (2 ** 31) - 1;
const POINTS_TO_BUFFER = 2;
export default class PencilPointerListener extends Component {
constructor() {
super();
// to track the status of drawing
this.isDrawing = false;
this.points = [];
this.handlePointerDown = this.handlePointerDown.bind(this);
this.handlePointerUp = this.handlePointerUp.bind(this);
this.handlePointerMove = this.handlePointerMove.bind(this);
this.handlePointerCancle = this.handlePointerCancle.bind(this);
this.resetState = this.resetState.bind(this);
this.sendLastMessage = this.sendLastMessage.bind(this);
this.sendCoordinates = this.sendCoordinates.bind(this);
this.discardAnnotation = this.discardAnnotation.bind(this);
}
componentDidMount() {
// to send the last DRAW_END message in case if a user reloads the page while drawing
window.addEventListener('beforeunload', this.sendLastMessage);
}
componentWillUnmount() {
window.removeEventListener('beforeunload', this.sendLastMessage);
// sending the last message on componentDidUnmount
this.sendLastMessage();
}
commonDrawStartHandler(clientX, clientY) {
const {
actions,
} = this.props;
const {
getTransformedSvgPoint,
generateNewShapeId,
svgCoordinateToPercentages,
} = actions;
// changing isDrawing to true
this.isDrawing = true;
// sending the first message
let transformedSvgPoint = getTransformedSvgPoint(clientX, clientY);
// transforming svg coordinate to percentages relative to the slide width/height
transformedSvgPoint = svgCoordinateToPercentages(transformedSvgPoint);
// sending the first message
this.points = [transformedSvgPoint.x, transformedSvgPoint.y];
this.handleDrawPencil(this.points, DRAW_START, generateNewShapeId());
}
commonDrawMoveHandler(clientX, clientY) {
if (this.isDrawing) {
const {
actions,
} = this.props;
const {
checkIfOutOfBounds,
getTransformedSvgPoint,
svgCoordinateToPercentages,
} = actions;
// get the transformed svg coordinate
let transformedSvgPoint = getTransformedSvgPoint(clientX, clientY);
// check if it's out of bounds
transformedSvgPoint = checkIfOutOfBounds(transformedSvgPoint);
// transforming svg coordinate to percentages relative to the slide width/height
transformedSvgPoint = svgCoordinateToPercentages(transformedSvgPoint);
// saving the coordinate to the array
this.points.push(transformedSvgPoint.x);
this.points.push(transformedSvgPoint.y);
if (this.points.length > POINTS_TO_BUFFER) {
this.sendCoordinates();
}
}
}
sendCoordinates() {
if (this.isDrawing && this.points.length > 0) {
const {
actions,
} = this.props;
const { getCurrentShapeId } = actions;
this.handleDrawPencil(this.points, DRAW_UPDATE, getCurrentShapeId());
}
}
handleDrawPencil(points, status, id, dimensions) {
const {
whiteboardId,
userId,
actions,
drawSettings,
} = this.props;
const {
normalizeThickness,
sendAnnotation,
} = actions;
const {
thickness,
color,
} = drawSettings;
const annotation = {
id,
status,
annotationType: 'pencil',
annotationInfo: {
color,
thickness: normalizeThickness(thickness),
points,
id,
whiteboardId,
status,
type: 'pencil',
},
wbId: whiteboardId,
userId,
position: 0,
};
// dimensions are added to the 'DRAW_END', last message
if (dimensions) {
annotation.annotationInfo.dimensions = dimensions;
}
sendAnnotation(annotation, whiteboardId);
}
sendLastMessage() {
if (this.isDrawing) {
const {
physicalSlideWidth,
physicalSlideHeight,
actions,
} = this.props;
const { getCurrentShapeId } = actions;
this.handleDrawPencil(
this.points,
DRAW_END,
getCurrentShapeId(),
[Math.round(physicalSlideWidth), Math.round(physicalSlideHeight)],
);
this.resetState();
}
}
resetState() {
// resetting the current info
this.points = [];
this.isDrawing = false;
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointercancle', this.handlePointerCancle);
}
discardAnnotation() {
const {
actions,
} = this.props;
const {
getCurrentShapeId,
clearPreview,
} = actions;
this.resetState();
clearPreview(getCurrentShapeId());
}
handlePointerDown(event) {
switch (event.pointerType) {
case 'mouse': {
const isLeftClick = event.button === 0;
const isRightClick = event.button === 2;
if (!this.isDrawing) {
if (isLeftClick) {
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointermove', this.handlePointerMove);
const { clientX, clientY } = event;
this.commonDrawStartHandler(clientX, clientY);
}
// if you switch to a different window using Alt+Tab while mouse is down and release it
// it wont catch mouseUp and will keep tracking the movements. Thus we need this check.
} else if (isRightClick) {
this.discardAnnotation();
}
break;
}
case 'pen': {
this.touchPenDownHandler(event);
break;
}
case 'touch': {
this.touchPenDownHandler(event);
break;
}
default: {
Logger.error({ logCode: 'pencil_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
touchPenDownHandler(event) {
event.preventDefault();
if (!this.isDrawing) {
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointercancle', this.handlePointerCancle, true);
const { clientX, clientY } = event;
this.commonDrawStartHandler(clientX, clientY);
// if you switch to a different window using Alt+Tab while mouse is down and release it
// it wont catch mouseUp and will keep tracking the movements. Thus we need this check.
} else {
this.sendLastMessage();
}
}
handlePointerUp(event) {
switch (event.pointerType) {
case 'mouse': {
this.sendLastMessage();
break;
}
case 'pen': {
this.sendLastMessage();
break;
}
case 'touch': {
this.sendLastMessage();
break;
}
default: {
Logger.error({ logCode: 'pencil_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
handlePointerMove(event) {
switch (event.pointerType) {
case 'mouse': {
const { clientX, clientY } = event;
this.commonDrawMoveHandler(clientX, clientY);
break;
}
case 'pen': {
event.preventDefault();
const { clientX, clientY } = event;
this.commonDrawMoveHandler(clientX, clientY);
break;
}
case 'touch': {
event.preventDefault();
const { clientX, clientY } = event;
this.commonDrawMoveHandler(clientX, clientY);
break;
}
default: {
Logger.error({ logCode: 'pencil_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
handlePointerCancle(event) {
switch (event.pointerType) {
case 'pen': {
this.sendLastMessage();
break;
}
case 'touch': {
this.sendLastMessage();
break;
}
default: {
Logger.error({ logCode: 'pencil_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
render() {
const {
actions,
} = this.props;
const { contextMenuHandler } = actions;
const baseName = Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename;
const pencilDrawStyle = {
width: '100%',
height: '100%',
touchAction: 'none',
zIndex: MAX_Z_INDEX,
cursor: `url('${baseName}/resources/images/whiteboard-cursor/pencil.png') 2 22, default`,
};
return (
<div
onPointerDown={this.handlePointerDown}
role="presentation"
style={pencilDrawStyle}
onContextMenu={contextMenuHandler}
/>
);
}
}
PencilPointerListener.propTypes = {
// Defines a whiteboard id, which needed to publish an annotation message
whiteboardId: PropTypes.string.isRequired,
// Defines a user id, which needed to publish an annotation message
userId: PropTypes.string.isRequired,
// Defines the physical widith of the slide
physicalSlideWidth: PropTypes.number.isRequired,
// Defines the physical height of the slide
physicalSlideHeight: PropTypes.number.isRequired,
// Defines an object containing all available actions
actions: PropTypes.shape({
// Defines a function which transforms a coordinate from the window to svg coordinate system
getTransformedSvgPoint: PropTypes.func.isRequired,
// Defines a function which checks if the shape is out of bounds and returns
// appropriate coordinates
checkIfOutOfBounds: PropTypes.func.isRequired,
// Defines a function which receives an svg point and transforms it into
// percentage-based coordinates
svgCoordinateToPercentages: PropTypes.func.isRequired,
// Defines a function which returns a current shape id
getCurrentShapeId: PropTypes.func.isRequired,
// Defines a function which generates a new shape id
generateNewShapeId: PropTypes.func.isRequired,
// Defines a function which receives a thickness num and normalizes it before we send a message
normalizeThickness: PropTypes.func.isRequired,
// Defines a function which we use to publish a message to the server
sendAnnotation: PropTypes.func.isRequired,
}).isRequired,
drawSettings: PropTypes.shape({
// Annotation color
color: PropTypes.number.isRequired,
// Annotation thickness (not normalized)
thickness: PropTypes.number.isRequired,
}).isRequired,
// Defines if palm rejection is active or not
// palmRejection: PropTypes.bool.isRequired,
};

View File

@ -0,0 +1,449 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Logger from '/imports/startup/client/logger';
const ANNOTATION_CONFIG = Meteor.settings.public.whiteboard.annotations;
const DRAW_START = ANNOTATION_CONFIG.status.start;
const DRAW_UPDATE = ANNOTATION_CONFIG.status.update;
const DRAW_END = ANNOTATION_CONFIG.status.end;
// maximum value of z-index to prevent other things from overlapping
const MAX_Z_INDEX = (2 ** 31) - 1;
export default class ShapePointerListener extends Component {
constructor(props) {
super(props);
// there is no valid defaults for the coordinates, and we wouldn't want them anyway
this.initialCoordinate = {
x: undefined,
y: undefined,
};
this.lastSentCoordinate = {
x: undefined,
y: undefined,
};
this.currentCoordinate = {
x: undefined,
y: undefined,
};
// to track the status of drawing
this.isDrawing = false;
this.currentStatus = undefined;
this.handlePointerDown = this.handlePointerDown.bind(this);
this.handlePointerUp = this.handlePointerUp.bind(this);
this.handlePointerMove = this.handlePointerMove.bind(this);
this.handlePointerCancle = this.handlePointerCancle.bind(this);
this.resetState = this.resetState.bind(this);
this.sendLastMessage = this.sendLastMessage.bind(this);
this.sendCoordinates = this.sendCoordinates.bind(this);
}
componentDidMount() {
// to send the last message if the user refreshes the page while drawing
window.addEventListener('beforeunload', this.sendLastMessage);
}
componentWillUnmount() {
window.removeEventListener('beforeunload', this.sendLastMessage);
// sending the last message on componentDidUnmount
this.sendLastMessage();
}
commonDrawStartHandler(clientX, clientY) {
this.isDrawing = true;
const {
actions,
} = this.props;
const {
getTransformedSvgPoint,
generateNewShapeId,
svgCoordinateToPercentages,
} = actions;
// sending the first message
let transformedSvgPoint = getTransformedSvgPoint(clientX, clientY);
// transforming svg coordinate to percentages relative to the slide width/height
transformedSvgPoint = svgCoordinateToPercentages(transformedSvgPoint);
// generating new shape id
generateNewShapeId();
// setting the initial current status
this.currentStatus = DRAW_START;
// saving the coordinates for future references
this.initialCoordinate = {
x: transformedSvgPoint.x,
y: transformedSvgPoint.y,
};
this.currentCoordinate = {
x: transformedSvgPoint.x,
y: transformedSvgPoint.y,
};
// All the messages will be send on timer by sendCoordinates func
this.sendCoordinates();
}
commonDrawMoveHandler(clientX, clientY) {
if (!this.isDrawing) {
return;
}
const {
actions,
} = this.props;
const {
checkIfOutOfBounds,
getTransformedSvgPoint,
svgCoordinateToPercentages,
} = actions;
// get the transformed svg coordinate
let transformedSvgPoint = getTransformedSvgPoint(clientX, clientY);
// check if it's out of bounds
transformedSvgPoint = checkIfOutOfBounds(transformedSvgPoint);
// transforming svg coordinate to percentages relative to the slide width/height
transformedSvgPoint = svgCoordinateToPercentages(transformedSvgPoint);
// saving the last sent coordinate
this.currentCoordinate = transformedSvgPoint;
this.sendCoordinates();
}
sendCoordinates() {
const {
actions,
drawSettings,
} = this.props;
// check the current drawing status
if (!this.isDrawing) {
return;
}
// check if a current coordinate is not the same as an initial one
// it prevents us from drawing dots on random clicks
if (this.currentCoordinate.x === this.initialCoordinate.x
&& this.currentCoordinate.y === this.initialCoordinate.y) {
return;
}
// check if previously sent coordinate is not equal to a current one
if (this.currentCoordinate.x === this.lastSentCoordinate.x
&& this.currentCoordinate.y === this.lastSentCoordinate.y) {
return;
}
const { getCurrentShapeId } = actions;
this.handleDrawCommonAnnotation(
this.initialCoordinate,
this.currentCoordinate,
this.currentStatus,
getCurrentShapeId(),
drawSettings.tool,
);
this.lastSentCoordinate = this.currentCoordinate;
if (this.currentStatus === DRAW_START) {
this.currentStatus = DRAW_UPDATE;
}
}
sendLastMessage() {
const {
actions,
drawSettings,
} = this.props;
if (this.isDrawing) {
// make sure we are drawing and we have some coordinates sent for this shape before
// to prevent sending DRAW_END on a random mouse click
if (this.lastSentCoordinate.x && this.lastSentCoordinate.y) {
const { getCurrentShapeId } = actions;
this.handleDrawCommonAnnotation(
this.initialCoordinate,
this.currentCoordinate,
DRAW_END,
getCurrentShapeId(),
drawSettings.tool,
);
}
this.resetState();
}
}
resetState() {
// resetting the current drawing state
this.isDrawing = false;
this.currentStatus = undefined;
this.initialCoordinate = {
x: undefined,
y: undefined,
};
this.lastSentCoordinate = {
x: undefined,
y: undefined,
};
this.currentCoordinate = {
x: undefined,
y: undefined,
};
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointercancle', this.handlePointerCancle);
}
// since Rectangle / Triangle / Ellipse / Line have the same coordinate structure
// we use the same function for all of them
handleDrawCommonAnnotation(startPoint, endPoint, status, id, shapeType) {
const {
whiteboardId,
userId,
actions,
drawSettings,
} = this.props;
const {
normalizeThickness,
sendAnnotation,
} = actions;
const {
color,
thickness,
} = drawSettings;
const annotation = {
id,
status,
annotationType: shapeType,
annotationInfo: {
color,
thickness: normalizeThickness(thickness),
points: [
startPoint.x,
startPoint.y,
endPoint.x,
endPoint.y,
],
id,
whiteboardId,
status,
type: shapeType,
},
wbId: whiteboardId,
userId,
position: 0,
};
sendAnnotation(annotation, whiteboardId);
}
discardAnnotation() {
const {
actions,
} = this.props;
const {
getCurrentShapeId,
clearPreview,
} = actions;
this.resetState();
clearPreview(getCurrentShapeId());
}
handlePointerDown(event) {
switch (event.pointerType) {
case 'mouse': {
const isLeftClick = event.button === 0;
const isRightClick = event.button === 2;
if (!this.isDrawing) {
if (isLeftClick) {
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointermove', this.handlePointerMove);
const { clientX, clientY } = event;
this.commonDrawStartHandler(clientX, clientY);
}
// if you switch to a different window using Alt+Tab while mouse is down and release it
// it wont catch mouseUp and will keep tracking the movements. Thus we need this check.
} else if (isRightClick) {
this.discardAnnotation();
}
break;
}
case 'pen': {
this.touchPenDownHandler(event);
break;
}
case 'touch': {
this.touchPenDownHandler(event);
break;
}
default: {
Logger.error({ logCode: 'shape_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
touchPenDownHandler(event) {
event.preventDefault();
if (!this.isDrawing) {
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointercancle', this.handlePointerCancle, true);
const { clientX, clientY } = event;
this.commonDrawStartHandler(clientX, clientY);
// if you switch to a different window using Alt+Tab while mouse is down and release it
// it wont catch mouseUp and will keep tracking the movements. Thus we need this check.
} else {
this.sendLastMessage();
}
}
handlePointerUp(event) {
switch (event.pointerType) {
case 'mouse': {
this.sendLastMessage();
break;
}
case 'pen': {
this.sendLastMessage();
break;
}
case 'touch': {
this.sendLastMessage();
break;
}
default: {
Logger.error({ logCode: 'shape_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
handlePointerMove(event) {
switch (event.pointerType) {
case 'mouse': {
const { clientX, clientY } = event;
this.commonDrawMoveHandler(clientX, clientY);
break;
}
case 'pen': {
event.preventDefault();
const { clientX, clientY } = event;
this.commonDrawMoveHandler(clientX, clientY);
break;
}
case 'touch': {
event.preventDefault();
const { clientX, clientY } = event;
this.commonDrawMoveHandler(clientX, clientY);
break;
}
default: {
Logger.error({ logCode: 'shape_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
handlePointerCancle(event) {
switch (event.pointerType) {
case 'pen': {
this.sendLastMessage();
break;
}
case 'touch': {
this.sendLastMessage();
break;
}
default: {
Logger.error({ logCode: 'shape_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
render() {
const {
actions,
drawSettings,
} = this.props;
const {
contextMenuHandler,
} = actions;
const {
tool,
} = drawSettings;
const baseName = Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename;
const shapeDrawStyle = {
width: '100%',
height: '100%',
touchAction: 'none',
zIndex: MAX_Z_INDEX,
cursor: `url('${baseName}/resources/images/whiteboard-cursor/${tool !== 'rectangle' ? tool : 'square'}.png'), default`,
};
return (
<div
onPointerDown={this.handlePointerDown}
role="presentation"
style={shapeDrawStyle}
onContextMenu={contextMenuHandler}
/>
);
}
}
ShapePointerListener.propTypes = {
// Defines a whiteboard id, which needed to publish an annotation message
whiteboardId: PropTypes.string.isRequired,
// Defines a user id, which needed to publish an annotation message
userId: PropTypes.string.isRequired,
actions: PropTypes.shape({
// Defines a function which transforms a coordinate from the window to svg coordinate system
getTransformedSvgPoint: PropTypes.func.isRequired,
// Defines a function which checks if the shape is out of bounds and returns
// appropriate coordinates
checkIfOutOfBounds: PropTypes.func.isRequired,
// Defines a function which receives an svg point and transforms it into
// percentage-based coordinates
svgCoordinateToPercentages: PropTypes.func.isRequired,
// Defines a function which returns a current shape id
getCurrentShapeId: PropTypes.func.isRequired,
// Defines a function which generates a new shape id
generateNewShapeId: PropTypes.func.isRequired,
// Defines a function which receives a thickness num and normalizes it before we send a message
normalizeThickness: PropTypes.func.isRequired,
// Defines a function which we use to publish a message to the server
sendAnnotation: PropTypes.func.isRequired,
}).isRequired,
drawSettings: PropTypes.shape({
// Annotation color
color: PropTypes.number.isRequired,
// Annotation thickness (not normalized)
thickness: PropTypes.number.isRequired,
// The name of the tool currently selected
tool: PropTypes.string,
}).isRequired,
};

View File

@ -0,0 +1,619 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Logger from '/imports/startup/client/logger';
const ANNOTATION_CONFIG = Meteor.settings.public.whiteboard.annotations;
const DRAW_START = ANNOTATION_CONFIG.status.start;
const DRAW_UPDATE = ANNOTATION_CONFIG.status.update;
const DRAW_END = ANNOTATION_CONFIG.status.end;
// maximum value of z-index to prevent other things from overlapping
const MAX_Z_INDEX = (2 ** 31) - 1;
export default class TextPointerListener extends Component {
constructor() {
super();
this.state = {
// text shape state properties
textBoxX: undefined,
textBoxY: undefined,
textBoxWidth: 0,
textBoxHeight: 0,
// to track the status of drawing
isDrawing: false,
// to track the status of writing a text shape after the textarea has been drawn
isWritingText: false,
};
// initial mousedown coordinates
this.initialX = undefined;
this.initialY = undefined;
// current X, Y, width and height in percentages of the current slide
// saving them so that we won't have to recalculate these values on each update
this.currentX = undefined;
this.currentY = undefined;
this.currentWidth = undefined;
this.currentHeight = undefined;
// current text shape status, it may change between DRAW_START, DRAW_UPDATE, DRAW_END
this.currentStatus = '';
// Mobile Firefox has a bug where e.preventDefault on touchstart doesn't prevent
// onmousedown from triggering right after. Thus we have to track it manually.
// In case if it's fixed one day - there is another issue, React one.
// https://github.com/facebook/react/issues/9809
// Check it to figure if you can add onTouchStart in render(), or should use raw DOM api
this.hasBeenTouchedRecently = false;
this.handlePointerDown = this.handlePointerDown.bind(this);
this.handlePointerUp = this.handlePointerUp.bind(this);
this.handlePointerMove = this.handlePointerMove.bind(this);
this.handlePointerCancle = this.handlePointerCancle.bind(this);
this.resetState = this.resetState.bind(this);
this.sendLastMessage = this.sendLastMessage.bind(this);
this.checkTextAreaFocus = this.checkTextAreaFocus.bind(this);
}
componentDidMount() {
window.addEventListener('beforeunload', this.sendLastMessage);
}
// If the activeId suddenly became empty - this means the shape was deleted
// While the user was drawing it. So we are resetting the state.
componentWillReceiveProps(nextProps) {
const { drawSettings } = this.props;
const nextDrawsettings = nextProps.drawSettings;
if (drawSettings.textShapeActiveId !== '' && nextDrawsettings.textShapeActiveId === '') {
this.resetState();
}
}
componentDidUpdate(prevProps) {
const {
drawSettings,
actions,
} = this.props;
const prevDrawsettings = prevProps.drawSettings;
const prevTextShapeValue = prevProps.drawSettings.textShapeValue;
// Updating the component in cases when:
// Either color / font-size or text value has changed
// and excluding the case when the textShapeActiveId changed to ''
const fontSizeChanged = drawSettings.textFontSize !== prevDrawsettings.textFontSize;
const colorChanged = drawSettings.color !== prevDrawsettings.color;
const textShapeValueChanged = drawSettings.textShapeValue !== prevTextShapeValue;
const textShapeIdNotEmpty = drawSettings.textShapeActiveId !== '';
if ((fontSizeChanged || colorChanged || textShapeValueChanged) && textShapeIdNotEmpty) {
const { getCurrentShapeId } = actions;
this.currentStatus = DRAW_UPDATE;
this.handleDrawText(
{ x: this.currentX, y: this.currentY },
this.currentWidth,
this.currentHeight,
this.currentStatus,
getCurrentShapeId(),
drawSettings.textShapeValue,
);
}
}
componentWillUnmount() {
window.removeEventListener('beforeunload', this.sendLastMessage);
// sending the last message on componentDidUnmount
// for example in case when you switched a tool while drawing text shape
this.sendLastMessage();
}
// checks if the input textarea is focused or not, and if not - moves focus there
// returns false if text area wasn't focused
// returns true if textarea was focused
// currently used only with iOS devices
checkTextAreaFocus() {
const {
actions,
} = this.props;
const { getCurrentShapeId } = actions;
const textarea = document.getElementById(getCurrentShapeId());
if (textarea) {
if (document.activeElement === textarea) {
return true;
}
textarea.focus();
}
return false;
}
commonDrawStartHandler(clientX, clientY) {
const {
actions,
} = this.props;
const {
getTransformedSvgPoint,
} = actions;
const transformedSvgPoint = getTransformedSvgPoint(clientX, clientY);
// saving initial X and Y coordinates for further displaying of the textarea
this.initialX = transformedSvgPoint.x;
this.initialY = transformedSvgPoint.y;
this.setState({
textBoxX: transformedSvgPoint.x,
textBoxY: transformedSvgPoint.y,
isDrawing: true,
});
}
sendLastMessage() {
const {
drawSettings,
actions,
} = this.props;
const {
isWritingText,
} = this.state;
if (!isWritingText) {
return;
}
const {
getCurrentShapeId,
} = actions;
this.currentStatus = DRAW_END;
this.handleDrawText(
{ x: this.currentX, y: this.currentY },
this.currentWidth,
this.currentHeight,
this.currentStatus,
getCurrentShapeId(),
drawSettings.textShapeValue,
);
this.resetState();
}
resetState() {
const {
actions,
} = this.props;
// resetting the text shape session values
actions.resetTextShapeSession();
// resetting the current state
this.currentX = undefined;
this.currentY = undefined;
this.currentWidth = undefined;
this.currentHeight = undefined;
this.currentStatus = '';
this.initialX = undefined;
this.initialY = undefined;
this.setState({
isDrawing: false,
isWritingText: false,
textBoxX: undefined,
textBoxY: undefined,
textBoxWidth: 0,
textBoxHeight: 0,
});
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointercancle', this.handlePointerCancle);
}
commonDrawMoveHandler(clientX, clientY) {
const {
actions,
} = this.props;
const {
checkIfOutOfBounds,
getTransformedSvgPoint,
} = actions;
// get the transformed svg coordinate
let transformedSvgPoint = getTransformedSvgPoint(clientX, clientY);
// check if it's out of bounds
transformedSvgPoint = checkIfOutOfBounds(transformedSvgPoint);
// check if we need to use initial or new coordinates for the top left corner of the rectangle
const x = transformedSvgPoint.x < this.initialX ? transformedSvgPoint.x : this.initialX;
const y = transformedSvgPoint.y < this.initialY ? transformedSvgPoint.y : this.initialY;
// calculating the width and height of the displayed text box
const width = transformedSvgPoint.x > this.initialX
? transformedSvgPoint.x - this.initialX : this.initialX - transformedSvgPoint.x;
const height = transformedSvgPoint.y > this.initialY
? transformedSvgPoint.y - this.initialY : this.initialY - transformedSvgPoint.y;
this.setState({
textBoxWidth: width,
textBoxHeight: height,
textBoxX: x,
textBoxY: y,
});
}
commonDrawEndHandler() {
const {
actions,
slideWidth,
slideHeight,
} = this.props;
const {
isDrawing,
isWritingText,
textBoxX,
textBoxY,
textBoxWidth,
textBoxHeight,
} = this.state;
// TODO - find if the size is large enough to display the text area
if (!isDrawing && isWritingText) {
return;
}
const {
generateNewShapeId,
getCurrentShapeId,
setTextShapeActiveId,
} = actions;
// coordinates and width/height of the textarea in percentages of the current slide
// saving them in the class since they will be used during all updates
this.currentX = (textBoxX / slideWidth) * 100;
this.currentY = (textBoxY / slideHeight) * 100;
this.currentWidth = (textBoxWidth / slideWidth) * 100;
this.currentHeight = (textBoxHeight / slideHeight) * 100;
this.currentStatus = DRAW_START;
this.handleDrawText(
{ x: this.currentX, y: this.currentY },
this.currentWidth,
this.currentHeight,
this.currentStatus,
generateNewShapeId(),
'',
);
setTextShapeActiveId(getCurrentShapeId());
this.setState({
isWritingText: true,
isDrawing: false,
textBoxX: undefined,
textBoxY: undefined,
textBoxWidth: 0,
textBoxHeight: 0,
});
}
handleDrawText(startPoint, width, height, status, id, text) {
const {
whiteboardId,
userId,
actions,
drawSettings,
} = this.props;
const {
normalizeFont,
sendAnnotation,
} = actions;
const {
color,
textFontSize,
} = drawSettings;
const annotation = {
id,
status,
annotationType: 'text',
annotationInfo: {
x: startPoint.x, // left corner
y: startPoint.y, // left corner
fontColor: color,
calcedFontSize: normalizeFont(textFontSize), // fontsize
textBoxWidth: width, // width
text,
textBoxHeight: height, // height
id,
whiteboardId,
status,
fontSize: textFontSize,
dataPoints: `${startPoint.x},${startPoint.y}`,
type: 'text',
},
wbId: whiteboardId,
userId,
position: 0,
};
sendAnnotation(annotation, whiteboardId);
}
discardAnnotation() {
const {
actions,
} = this.props;
const {
getCurrentShapeId,
clearPreview,
} = actions;
this.resetState();
clearPreview(getCurrentShapeId());
}
handlePointerDown(event) {
switch (event.pointerType) {
case 'mouse': {
const {
isDrawing,
isWritingText,
} = this.state;
const isLeftClick = event.button === 0;
const isRightClick = event.button === 2;
if (this.hasBeenTouchedRecently) {
return;
}
// if our current drawing state is not drawing the box and not writing the text
if (!isDrawing && !isWritingText) {
if (isLeftClick) {
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointermove', this.handlePointerMove);
const { clientX, clientY } = event;
this.commonDrawStartHandler(clientX, clientY);
}
// second case is when a user finished writing the text and publishes the final result
} else if (isRightClick) {
this.discardAnnotation();
} else {
// publishing the final shape and resetting the state
this.sendLastMessage();
}
break;
}
case 'pen': {
this.touchPenDownHandler(event);
break;
}
case 'touch': {
this.touchPenDownHandler(event);
break;
}
default: {
Logger.error({ logCode: 'text_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
touchPenDownHandler(event) {
const {
isDrawing,
isWritingText,
} = this.state;
this.hasBeenTouchedRecently = true;
setTimeout(() => { this.hasBeenTouchedRecently = false; }, 500);
// to prevent default behavior (scrolling) on devices (in Safari), when you draw a text box
event.preventDefault();
if (!isDrawing && !isWritingText) {
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointercancle', this.handlePointerCancle, true);
const { clientX, clientY } = event;
this.commonDrawStartHandler(clientX, clientY);
// this case is specifically for iOS, since text shape is working in 3 steps there:
// touch to draw a box -> tap to focus -> tap to publish
} else if (!isDrawing && isWritingText && !this.checkTextAreaFocus()) {
// if you switch to a different window using Alt+Tab while mouse is down and release it
// it wont catch mouseUp and will keep tracking the movements. Thus we need this check.
} else {
this.sendLastMessage();
}
}
handlePointerUp(event) {
switch (event.pointerType) {
case 'mouse': {
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointermove', this.handlePointerMove);
this.commonDrawEndHandler();
break;
}
case 'pen': {
this.touchPenEndHandler();
break;
}
case 'touch': {
this.touchPenEndHandler();
break;
}
default: {
Logger.error({ logCode: 'text_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
touchPenEndHandler() {
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointercancle', this.handlePointerCancle);
this.commonDrawEndHandler();
}
handlePointerMove(event) {
switch (event.pointerType) {
case 'mouse': {
const { clientX, clientY } = event;
this.commonDrawMoveHandler(clientX, clientY);
break;
}
case 'pen': {
event.preventDefault();
const { clientX, clientY } = event;
this.commonDrawMoveHandler(clientX, clientY);
break;
}
case 'touch': {
event.preventDefault();
const { clientX, clientY } = event;
this.commonDrawMoveHandler(clientX, clientY);
break;
}
default: {
Logger.error({ logCode: 'text_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
handlePointerCancle(event) {
switch (event.pointerType) {
case 'pen': {
this.touchPenEndHandler();
break;
}
case 'touch': {
this.touchPenEndHandler();
break;
}
default: {
Logger.error({ logCode: 'text_pointer_listener_unkownPointerTypeError' }, 'PointerType is unknown or could not be detected!');
}
}
}
render() {
const {
actions,
} = this.props;
const {
textBoxX,
textBoxY,
textBoxWidth,
textBoxHeight,
isWritingText,
isDrawing,
} = this.state;
const { contextMenuHandler } = actions;
const baseName = Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename;
const textDrawStyle = {
width: '100%',
height: '100%',
touchAction: 'none',
zIndex: MAX_Z_INDEX,
cursor: `url('${baseName}/resources/images/whiteboard-cursor/text.png'), default`,
};
return (
<div
role="presentation"
style={textDrawStyle}
onPointerDown={this.handlePointerDown}
onContextMenu={contextMenuHandler}
>
{isDrawing
? (
<svg
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
>
{!isWritingText
? (
<rect
x={textBoxX}
y={textBoxY}
fill="none"
stroke="black"
strokeWidth="1"
width={textBoxWidth}
height={textBoxHeight}
/>
)
: null }
</svg>
)
: null }
</div>
);
}
}
TextPointerListener.propTypes = {
// Defines a whiteboard id, which needed to publish an annotation message
whiteboardId: PropTypes.string.isRequired,
// Defines a user id, which needed to publish an annotation message
userId: PropTypes.string.isRequired,
// Width of the slide (svg coordinate system)
slideWidth: PropTypes.number.isRequired,
// Height of the slide (svg coordinate system)
slideHeight: PropTypes.number.isRequired,
// Current draw settings passed from the toolbar and text shape (text value)
drawSettings: PropTypes.shape({
// Annotation color
color: PropTypes.number.isRequired,
// Font size for the text shape
textFontSize: PropTypes.number.isRequired,
// Current active text shape value
textShapeValue: PropTypes.string.isRequired,
// Text active text shape id
textShapeActiveId: PropTypes.string.isRequired,
}).isRequired,
actions: PropTypes.shape({
// Defines a function which transforms a coordinate from the window to svg coordinate system
getTransformedSvgPoint: PropTypes.func.isRequired,
// Defines a function which checks if the shape is out of bounds and returns
// appropriate coordinates
checkIfOutOfBounds: PropTypes.func.isRequired,
// Defines a function which returns a current shape id
getCurrentShapeId: PropTypes.func.isRequired,
// Defines a function which generates a new shape id
generateNewShapeId: PropTypes.func.isRequired,
// Defines a function which receives a thickness num and normalizes it before we send a message
normalizeFont: PropTypes.func.isRequired,
// Defines a function which we use to publish a message to the server
sendAnnotation: PropTypes.func.isRequired,
// Defines a function which resets the current state of the text shape drawing
resetTextShapeSession: PropTypes.func.isRequired,
// Defines a function that sets a session value for the current active text shape
setTextShapeActiveId: PropTypes.func.isRequired,
}).isRequired,
};