diff --git a/site/src/components/ShapeBuilder/index.js b/site/src/components/ShapeBuilder/index.js index ecbc32a..6f05baf 100644 --- a/site/src/components/ShapeBuilder/index.js +++ b/site/src/components/ShapeBuilder/index.js @@ -1,6 +1,6 @@ // /* global window */ import React, { useEffect, useRef, useState } from "react"; -import { Wrapper, CanvasContainer, OutputBox, StyledSVG, CopyButton } from "./shapeBuilder.styles"; +import { Wrapper, CanvasContainer, OutputBox, StyledSVG, CopyButton, CoordinateDisplay } from "./shapeBuilder.styles"; import { Button, Typography, Box, CopyIcon, Select, MenuItem, Slider, FormControl } from "@sistent/sistent"; import { SVG, extend as SVGextend } from "@svgdotjs/svg.js"; import draw from "@svgdotjs/svg.draw.js"; @@ -12,6 +12,43 @@ const MIN_SCALE = 0.1; const MAX_SCALE = 3; const MIN_POLYGON_POINTS = 3; +// Gap in px kept between the pointer and the coordinate readout. +const READOUT_GAP = 16; +// Decimal places shown in the readout. +const READOUT_PRECISION = 3; + +/* + * Maps a point in canvas pixels onto -1..1 relative to the canvas centre. + * Note this is measured off the live element, whereas `showCytoArray` still + * normalizes against a hardcoded 260px half-extent; the two agree only while + * the canvas is 520px square, which it is not at most viewport widths. That + * hardcoded divisor predates this feature and is left for a separate change so + * the polygon output contract is not altered here. + */ +const normalizeToCanvas = (x, y, rect) => [ + (x - rect.width / 2) / (rect.width / 2), + (y - rect.height / 2) / (rect.height / 2) +]; + +/* + * Anchors the readout to whichever pair of container edges keeps it on screen. + * Anchoring the far side with `right`/`bottom` means the readout never has to be + * measured to know it will not be clipped near the canvas edge. + */ +const buildReadoutAnchor = (x, y, rect) => { + const anchor = x > rect.width / 2 + ? { right: `${Math.round(rect.width - x + READOUT_GAP)}px` } + : { left: `${Math.round(x + READOUT_GAP)}px` }; + + if (y > rect.height / 2) { + anchor.bottom = `${Math.round(rect.height - y + READOUT_GAP)}px`; + } else { + anchor.top = `${Math.round(y + READOUT_GAP)}px`; + } + + return anchor; +}; + const ShapeBuilder = () => { const boardRef = useRef(null); const polyRef = useRef(null); @@ -23,6 +60,13 @@ const ShapeBuilder = () => { const [scale, setScale] = useState(1); const [currentPreset, setCurrentPreset] = useState(1); + // `null` whenever the pointer is off the canvas, so position and visibility + // can never disagree. + const [readout, setReadout] = useState(null); + const [showCoordinates, setShowCoordinates] = useState(true); + const readoutFrameRef = useRef(0); + const pendingReadoutRef = useRef(null); + const handleCopyToClipboard = async () => { if (!result.trim()) return; @@ -90,6 +134,52 @@ const ShapeBuilder = () => { showCytoArray(); }; + const cancelReadoutFrame = () => { + if (readoutFrameRef.current) { + window.cancelAnimationFrame(readoutFrameRef.current); + readoutFrameRef.current = 0; + } + pendingReadoutRef.current = null; + }; + + // Pointer events fire faster than the browser paints, so coalesce them onto a + // single animation frame rather than re-rendering once per event. + const scheduleReadout = (next) => { + pendingReadoutRef.current = next; + if (readoutFrameRef.current) return; + + readoutFrameRef.current = window.requestAnimationFrame(() => { + readoutFrameRef.current = 0; + setReadout(pendingReadoutRef.current); + }); + }; + + // Pointer events cover mouse, pen and touch with one standard API supported by + // every current browser; `currentTarget` is always the canvas the handler is + // bound to, even when the event bubbles up from a drawn shape. + const handlePointerMove = (e) => { + const rect = e.currentTarget.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const [normalizedX, normalizedY] = normalizeToCanvas(x, y, rect); + + scheduleReadout({ + anchor: buildReadoutAnchor(x, y, rect), + x: normalizedX.toFixed(READOUT_PRECISION), + y: normalizedY.toFixed(READOUT_PRECISION) + }); + }; + + // Covers pointerleave and pointercancel: a touch or pen stream that is taken + // over by the browser never emits a leave, and would otherwise strand the + // readout on screen. + const hideReadout = () => { + cancelReadoutFrame(); + setReadout(null); + }; + const handleScaleChange = (newScale) => { const clampedScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale)); setScale(clampedScale); @@ -111,6 +201,10 @@ const ShapeBuilder = () => { handleScaleChange(newValue); }; + const toggleCoordinates = () => { + setShowCoordinates((prev) => !prev); + }; + const handleKeyDown = (e) => { const poly = polyRef.current; if (!poly) return; @@ -208,6 +302,7 @@ const ShapeBuilder = () => { useEffect(() => { initializeDrawing(); return () => { + cancelReadoutFrame(); detachKeyListeners(); if (polyRef.current) { polyRef.current.draw("cancel"); @@ -225,6 +320,10 @@ const ShapeBuilder = () => { width="100%" height="100%" onDoubleClick={closeShape} + onPointerMove={handlePointerMove} + onPointerEnter={handlePointerMove} + onPointerLeave={hideReadout} + onPointerCancel={hideReadout} > @@ -233,6 +332,14 @@ const ShapeBuilder = () => { + + {showCoordinates && readout && ( + /* Decorative, pointer-only overlay: keep it out of the a11y tree. */ + + )} + {error && (
{ + diff --git a/site/src/components/ShapeBuilder/shapeBuilder.styles.js b/site/src/components/ShapeBuilder/shapeBuilder.styles.js index aa53f48..2a964f8 100644 --- a/site/src/components/ShapeBuilder/shapeBuilder.styles.js +++ b/site/src/components/ShapeBuilder/shapeBuilder.styles.js @@ -1,5 +1,22 @@ import styled from "styled-components"; -// import styled from "@sistent/sistent"; +import { createTheme, darkModePalette, lightModePalette, typography } from "@sistent/sistent"; + +/* + * Sistent design tokens. + * + * styled-components resolves `theme` from the styled-components ThemeProvider in + * src/pages/index.js, which supplies this site's local theme - Sistent's MUI + * theme lives in a separate (emotion) context and is not reachable from here. + * So Sistent's tokens are read straight from the library's exported token sets + * and selected with the same `theme.mode` flag the rest of the site keys off. + */ +const sistentBase = createTheme(); + +const sistentPalette = ({ theme }) => + (theme?.mode === "light" ? lightModePalette : darkModePalette); + +const sistentTypography = ({ theme }) => + typography(theme?.mode === "light" ? "light" : "dark"); // NOTE: background colors are hardcoded-temporarily for testing @@ -138,6 +155,38 @@ export const CopyButton = styled.button` } `; +export const CoordinateDisplay = styled.div` + position: absolute; + /* Never intercept pointer events - the readout sits over the drawing surface. */ + pointer-events: none; + z-index: ${sistentBase.zIndex.tooltip}; + + padding: ${sistentBase.spacing(0.75)} ${sistentBase.spacing(1.25)}; + border: 1px solid ${(props) => sistentPalette(props).border.brand}; + border-radius: ${sistentBase.shape.borderRadius}px; + background-color: ${(props) => sistentPalette(props).background.elevatedComponents}; + color: ${(props) => sistentPalette(props).text.default}; + box-shadow: ${sistentBase.shadows[2]}; + + /* + * Brand font only. "Qanelas Soft" is the family name declared in + * src/fonts.css and used by src/styles/styles.js; Sistent's own token spells + * it "Qanelas Soft Regular", which this site does not load, so the family is + * named here and only the size/weight/rhythm come from the Sistent scale. + */ + font-family: "Qanelas Soft"; + font-size: ${(props) => sistentTypography(props).textL1Bold.fontSize}; + font-weight: ${(props) => sistentTypography(props).textL1Bold.fontWeight}; + line-height: ${(props) => sistentTypography(props).textL1Bold.lineHeight}; + /* Keeps the readout from twitching as digits change width. */ + font-variant-numeric: tabular-nums; + + white-space: nowrap; + /* WebKit still needs the prefix for user-select. */ + -webkit-user-select: none; + user-select: none; +`; + // export const Wrapper = styled.div` // padding: 2rem; // background-color: ${({ theme }) => theme.palette.background.default};