import styled from "@emotion/styled";
import html2canvas from "html2canvas";
import { inject, observer } from "mobx-react";
import React, { useCallback, useEffect, useRef } from "react";
import { useMutation } from "react-apollo";
import { v4 } from "uuid";
import { LIGHT_SECONDARY_FIVE } from "../../../shared/colors";
import {
SlideAirtable,
SlideBoard,
SlideComment,
SlideConnection,
SlideIdea,
SlideObjectType,
SlideRectangle,
SlideText,
} from "../../../shared/types";
import { CreateFrame, CurrentUser_me } from "../../graphql/generated/types";
import { CREATE_FRAME } from "../../graphql/mutations";
import { SocketActions, StoreProps } from "../../platform/SlideshowStore";
import withPlatform, { PlatformProps } from "../../platform/withPlatform";
import {
CommandLineModes,
CommandLineSource,
CommandPriority,
ProwessCommands,
useCommandLineContext,
} from "../../providers/CommandLineProvider";
import MouseList from "../mouse/MouseList";
import Airtable from "./Airtable";
import Board from "./Board";
import {
canvasStyleDictionary,
setStyle,
setUpNonRenderStyles,
snapToFrame,
snapToItem,
} from "./canvasStyle";
import {
clamp,
INTERNAL_CANVAS_HEIGHT,
INTERNAL_CANVAS_WIDTH,
} from "./canvasUtils";
import Comment from "./Comment";
import Connection from "./Connection";
import Idea from "./Idea";
import PendingConnection from "./PendingConnection";
import Rectangle from "./Rectangle";
import SelectRectangle from "./SelectRectangle";
import { setGlobalStyle } from "./style";
import Text from "./Text";
import {
addWindowListeners,
currentKeys,
CurrentScreen,
deleteConnections,
getCanvasPosition,
removeStore,
removeWindowListeners,
setStore,
} from "./windowListeners";
type Props = {
user: CurrentUser_me;
} & PlatformProps &
StoreProps;
function Canvas(props: Props) {
const containerRef = useRef(null);
const canvasRef = useRef(null);
const currentSlide = props.store.slides.get(props.store.currentSlide.get()!)!;
const {
isVisible,
toggle,
addCommands,
setPrompt,
setIsVisible,
addOptions,
setMode,
setPromptCallback,
setPlaceholder,
clearCommandsFromSource,
} = useCommandLineContext();
const [createFrame] = useMutation(CREATE_FRAME);
const commands = [
{
id: ProwessCommands.CREATE_FRAME,
priority: CommandPriority.LOW,
source: CommandLineSource.CANVAS,
title: "Create Frame",
shortcut: "f",
onSelect: () => {
setPrompt("Add a view name");
setIsVisible(true);
setMode(CommandLineModes.PROMPT);
function promptCallback() {
return async (prompt: string) => {
if (!prompt) {
resetCommands();
return null;
}
setIsVisible(false);
setPlaceholder("Start typing...");
const containerBox = containerRef.current!.getBoundingClientRect();
const centerTop =
canvasStyleDictionary[currentSlide.id].top -
containerBox.height / 2;
const centerLeft =
canvasStyleDictionary[currentSlide.id].left -
containerBox.width / 2;
await createFrame({
variables: {
input: {
title: prompt,
top: centerTop,
left: centerLeft,
scale: canvasStyleDictionary[currentSlide.id].scale,
slideshowId: props.store.roomId.get()!,
slideId: currentSlide.id,
},
},
/*
TODO: This doesn't work anymore because it causes the parent component
wrapping the mobx store to update, which changes the store.
update: (cache, result) => {
const frame = result!.data!.createFrame!;
const currentSlideshowId = props.store.roomId.get()!;
const currentSlideshow = props.user.team.slideshows.find(
(slideshow) => slideshow.id === currentSlideshowId
)!;
const oldData = cache.readQuery({
query: CURRENT_USER,
});
const newData = produce(oldData, (draft) => {
const slideshow = draft!.me!.team.slideshows.find(
(slideshow) => slideshow.id === currentSlideshowId
)!;
slideshow.frames = [...slideshow.frames, frame];
});
cache.writeQuery({
query: CURRENT_USER,
data: newData,
});
},
*/
}).catch((err) => console.log(err));
resetCommands();
return null;
};
}
setPromptCallback(promptCallback);
setPlaceholder("Your frame name");
setIsVisible(true);
},
onCancel: () => resetCommands(),
},
{
id: ProwessCommands.GOTO_FRAME,
priority: CommandPriority.LOW,
source: CommandLineSource.CANVAS,
title: "Snap to Frame",
shortcut: "g",
onSelect: () => {
setPrompt("Choose a frame");
setPlaceholder("Frame name");
setIsVisible(true);
setMode(CommandLineModes.OPTION);
const currentSlideshowId = props.store.roomId.get()!;
const currentSlideshow = props.user.team.slideshows.find(
(slideshow) => slideshow.id === currentSlideshowId
)!;
const currentSlideId = props.store.currentSlide.get()!;
const containerBox = containerRef.current!.getBoundingClientRect();
addOptions([
...currentSlideshow.frames.map((frame) => {
return {
id: frame.id,
title: frame.title,
type: CommandLineModes.OPTION,
source: CommandLineSource.CANVAS,
priority: CommandPriority.HIGH,
onSelect: () => {
snapToFrame(frame, containerBox);
setIsVisible(false);
resetCommands();
},
onCancel: () => resetCommands(),
};
}),
]);
},
onCancel: () => resetCommands(),
},
];
const resetCommands = () => {
setMode(CommandLineModes.COMMAND);
clearCommandsFromSource(CommandLineSource.CANVAS);
// re-add top-level commands
addCommands(commands);
setPrompt("Prowess Command");
setPlaceholder("Start typing...");
};
useEffect(() => {
setUpNonRenderStyles(currentSlide.id);
props.manager.bind(
"t",
() => {
props.store.createItemSelector.set(SlideObjectType.TEXT);
},
"Canvas",
"Create a text field",
1
);
props.manager.bind(
"a",
() => {
props.store.createItemSelector.set(SlideObjectType.AIRTABLE);
},
"Canvas",
"Create an airtable embed",
1
);
props.manager.bind(
"c",
() => {
props.store.createItemSelector.set(SlideObjectType.COMMENT);
},
"Canvas",
"Create a comment",
1
);
props.manager.bind(
"r",
() => {
props.store.createItemSelector.set(SlideObjectType.RECTANGLE);
},
"Canvas",
"Create a rectangle",
1
);
props.manager.bind(
"b",
() => props.store.createItemSelector.set(SlideObjectType.BOARD),
"Canvas",
"Create a board",
1
);
props.manager.bind(
"esc",
() => {
if (isVisible) {
toggle();
return;
}
props.store.createItemSelector.set(null);
},
"Canvas",
"Remove any create selection.",
1
);
props.manager.bind(
"del",
() => {
deleteConnections(
props.store.selectedSlideElement.get()!,
currentSlide.id,
props.store
);
props.store.emit(SocketActions.DELETE_SLIDE_OBJECT, {
id: props.store.selectedSlideElement.get()!,
slideId: currentSlide.id,
});
props.store.setSelectedSlideElement(null);
},
"Canvas",
"Delete current slide element",
1
);
props.manager.bind(
"command+f",
(e: MouseEvent) => {
const ideas = Object.entries(currentSlide.content)
.filter(([key, slideItem]) => {
return slideItem.type === SlideObjectType.IDEA;
})
.map(([key, slideItem]) => ({
id: slideItem.id,
title: (slideItem as SlideIdea).title,
type: SlideObjectType.IDEA,
x: slideItem.x,
y: slideItem.y,
width: (slideItem as SlideIdea).width,
height: (slideItem as SlideIdea).height,
}));
const texts = Object.entries(currentSlide.content)
.filter(([key, slideItem]) => {
return slideItem.type === SlideObjectType.TEXT;
})
.map(([key, slideItem]) => ({
id: slideItem.id,
title: (slideItem as SlideText).text,
type: SlideObjectType.TEXT,
x: slideItem.x,
y: slideItem.y,
width: (slideItem as SlideText).width,
height: (slideItem as SlideText).height,
}));
const frames = props.user.team.slideshows
.find((slideshow) => slideshow.id === props.store.roomId.get()!)!
.frames.filter(
(frame) => frame.slide.id === props.store.currentSlide.get()!
)
.map((frame) => ({
id: frame.id,
title: frame.title,
type: "FRAME",
top: frame.top,
left: frame.left,
scale: frame.scale,
slide: frame.slide,
}));
const ideasAndFrames = [...ideas, ...texts, ...frames];
const containerBox = containerRef.current!.getBoundingClientRect();
setPrompt("Search");
setPlaceholder("Keyword");
setMode(CommandLineModes.OPTION);
addOptions(
ideasAndFrames.map((item) => {
return {
id: item.id,
title: item.title || "Untitled",
type: CommandLineModes.OPTION,
source: CommandLineSource.CANVAS,
priority: CommandPriority.HIGH,
onSelect: () => {
snapToItem(item, containerBox, props.store);
setIsVisible(false);
resetCommands();
},
onCancel: () => resetCommands(),
};
})
);
setIsVisible(true);
},
"Canvas",
"Search for an element",
1
);
setStore(props.store);
addWindowListeners();
addCommands(commands);
return () => {
props.manager.unbind("t", "Canvas");
props.manager.unbind("c", "Canvas");
props.manager.unbind("a", "Canvas");
props.manager.unbind("r", "Canvas");
props.manager.unbind("b", "Canvas");
props.manager.unbind("esc", "Canvas");
props.manager.unbind("del", "Canvas");
props.manager.unbind("command+f", "Canvas");
removeStore();
removeWindowListeners();
};
}, []);
const onMouseMove = useCallback((e: React.MouseEvent) => {
if (!canvasStyleDictionary[currentSlide.id]) {
return;
}
const top =
(e.clientY - canvasStyleDictionary[currentSlide.id].top) /
canvasStyleDictionary[currentSlide.id].scale;
const left =
(e.clientX - canvasStyleDictionary[currentSlide.id].left) /
canvasStyleDictionary[currentSlide.id].scale;
props.store.emit(SocketActions.MOUSE_MOVEMENT, {
x: left,
y: top,
userId: props.user.id,
timestamp: Date.now(),
});
e.persist();
}, []);
const onCanvasClick = useCallback((e: React.MouseEvent) => {
const canvasPosition = getCanvasPosition(e, props.store);
const createItemSelector = props.store.createItemSelector.get();
if (!createItemSelector) {
return null;
}
const newElementId = v4();
const x =
(e.clientX - canvasStyleDictionary[currentSlide.id].left) /
canvasStyleDictionary[currentSlide.id].scale;
const y =
(e.clientY - canvasStyleDictionary[currentSlide.id].top) /
canvasStyleDictionary[currentSlide.id].scale;
if (createItemSelector === SlideObjectType.TEXT) {
props.store.emit(SocketActions.CREATE_SLIDE_OBJECT, {
id: newElementId,
slideId: props.store.currentSlide.get(),
type: SlideObjectType.TEXT,
text: "Hello world!",
fontSize: 16,
x,
y,
width: 200,
height: 50,
});
} else if (createItemSelector === SlideObjectType.COMMENT) {
props.store.emit(SocketActions.CREATE_SLIDE_OBJECT, {
id: newElementId,
slideId: props.store.currentSlide.get(),
type: SlideObjectType.COMMENT,
x,
y,
});
} else if (createItemSelector === SlideObjectType.AIRTABLE) {
props.store.emit(SocketActions.CREATE_SLIDE_OBJECT, {
id: newElementId,
slideId: props.store.currentSlide.get(),
type: SlideObjectType.AIRTABLE,
x,
y,
});
} else if (createItemSelector === SlideObjectType.RECTANGLE) {
props.store.emit(SocketActions.CREATE_SLIDE_OBJECT, {
id: newElementId,
slideId: props.store.currentSlide.get(),
type: SlideObjectType.RECTANGLE,
x,
y,
width: 100,
height: 100,
});
} else if (createItemSelector === SlideObjectType.BOARD) {
props.store.emit(SocketActions.CREATE_SLIDE_OBJECT, {
id: newElementId,
slideId: props.store.currentSlide.get(),
type: SlideObjectType.BOARD,
x,
y,
width: 500,
height: 500,
});
}
props.store.setSelectedSlideElement(newElementId);
props.store.createItemSelector.set(null);
}, []);
const translateAndZoom = useCallback(
(event: React.WheelEvent) => {
event.preventDefault();
event.stopPropagation();
const viewRect = containerRef.current?.getBoundingClientRect()!;
const maxHorizontal =
(canvasStyleDictionary[currentSlide.id].scale * INTERNAL_CANVAS_WIDTH -
viewRect.width!) *
-1;
const maxVertical =
(canvasStyleDictionary[currentSlide.id].scale * INTERNAL_CANVAS_HEIGHT -
viewRect.height!) *
-1;
if (event.ctrlKey || event.metaKey) {
const coordChange = (coordinate: number, scaleRatio: number) => {
return scaleRatio * coordinate - coordinate;
};
const scaleFromPoint = (
newScale: number,
focalPoint: { x: number; y: number }
) => {
const previousScale = canvasStyleDictionary[currentSlide.id].scale;
const scaleRatio = newScale / previousScale;
const focalPointDelta = {
x: coordChange(focalPoint.x, scaleRatio),
y: coordChange(focalPoint.y, scaleRatio),
};
const newTranslation = {
x: canvasStyleDictionary[currentSlide.id].left - focalPointDelta.x,
y: canvasStyleDictionary[currentSlide.id].top - focalPointDelta.y,
};
const newMaxHorizontal =
(newScale * INTERNAL_CANVAS_WIDTH - viewRect.width!) * -1;
const newMaxVertical =
(newScale * INTERNAL_CANVAS_HEIGHT - viewRect.height!) * -1;
setStyle(currentSlide.id, {
scale: newScale,
top: clamp(newMaxVertical, newTranslation.y, 0),
left: clamp(newMaxHorizontal, newTranslation.x, 0),
});
};
const translatedOrigin = () => {
return {
x: viewRect.left + canvasStyleDictionary[currentSlide.id].left,
y: viewRect.top + canvasStyleDictionary[currentSlide.id].top,
};
};
const clientPosToTranslatedPos = ({
x,
y,
}: {
x: number;
y: number;
}) => {
const origin = translatedOrigin();
return {
x: x - origin.x,
y: y - origin.y,
};
};
const previousScale = canvasStyleDictionary[currentSlide.id].scale;
// TODO: limits
const scaleChange = 2 ** (event.deltaY * 0.002);
const newScale = clamp(0.25, previousScale + (1 - scaleChange), 4);
const mousePosition = clientPosToTranslatedPos({
x: event.clientX,
y: event.clientY,
});
scaleFromPoint(newScale, mousePosition);
return;
}
setStyle(currentSlide.id, {
scale: canvasStyleDictionary[currentSlide.id].scale,
top: clamp(
maxVertical,
canvasStyleDictionary[currentSlide.id].top + event.deltaY * -1,
0
),
left: clamp(
maxHorizontal,
canvasStyleDictionary[currentSlide.id].left + event.deltaX * -1,
0
),
});
},
[currentSlide]
);
const onMouseDown = useCallback((e: React.MouseEvent) => {
if (
e.button === 1 ||
(e.button === 0 && currentKeys["Space"]) /* spacebar */
) {
CurrentScreen.screenX = e.screenX;
CurrentScreen.screenY = e.screenY;
props.store.isCanvasDragging.set(true);
setGlobalStyle(`body {
cursor: grab;
}`);
e.preventDefault();
} else if (e.button === 0) {
// Absolute position on screen
// TODO: No multiselect right now so no need for window drag
// const currentPosition = getCanvasPosition(e, props.store);
// CurrentScreen.canvasX = currentPosition.x;
// CurrentScreen.canvasY = currentPosition.y;
// props.store.isSelectDragging.set(true);
}
}, []);
if (!currentSlide) {
return null;
}
const currentSlideshowId = props.store.roomId.get()!;
const currentSlideshow = props.user.team.slideshows.find(
(slideshow) => slideshow.id === currentSlideshowId
)!;
const relevantFrames = currentSlideshow.frames.filter(
(frame) => frame.slide.id === currentSlide.id
);
return (
{Object.keys(currentSlide.content).map((slideObjectKey, index) => {
const currentSlideObject = currentSlide.content[slideObjectKey];
switch (currentSlideObject.type) {
case SlideObjectType.TEXT:
return (
);
case SlideObjectType.AIRTABLE:
return null;
return (
);
case SlideObjectType.COMMENT:
return (
);
case SlideObjectType.RECTANGLE:
return (
);
case SlideObjectType.BOARD:
const boardItems = Object.entries(currentSlide.content)
.filter(([key, slideItem]) => {
return (
slideItem.type === SlideObjectType.IDEA &&
(slideItem as SlideIdea).boardId === slideObjectKey
);
})
.map(([key, slideItem]) => slideItem);
return (
);
case SlideObjectType.IDEA:
if ((currentSlideObject as SlideIdea).boardId) {
return null;
}
return (
);
default:
console.log("UNHANDLED");
return null;
}
})}
{relevantFrames.map((frame) => (
))}
);
}
export default withPlatform(inject("store")(observer(Canvas)));
const InternalCanvas = styled.div`
position: absolute;
display: flex;
width: ${INTERNAL_CANVAS_WIDTH}px;
height: ${INTERNAL_CANVAS_HEIGHT}px;
background-color: ${LIGHT_SECONDARY_FIVE};
`;
const Container = styled.div`
position: relative;
width: 100%;
height: 100%;
background-color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
`;