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) => (
{frame.title}
))} {Object.keys(currentSlide.content).map((slideObjectKey, index) => { const currentSlideObject = currentSlide.content[slideObjectKey]; if (currentSlideObject.type === SlideObjectType.CONNECTION) { return ( ); } return null; })} {/* Canvas layer for arrows */} {props.store.currentConnectingElement.get() && ( )}
); } 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; `;