diff --git a/VERSION b/VERSION index 4d01880..11d9d6c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.6 \ No newline at end of file +0.7.7 \ No newline at end of file diff --git a/package.json b/package.json index 4d60e0b..499f324 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftbase", - "version": "0.7.6", + "version": "0.7.7", "private": true, "main": "src/lib.ts", "module": "src/lib.ts", diff --git a/src/canvas/selectionController.ts b/src/canvas/selectionController.ts index fa73542..7b92044 100644 --- a/src/canvas/selectionController.ts +++ b/src/canvas/selectionController.ts @@ -62,8 +62,15 @@ const SHAPE_ADAPTERS: Record = { diamond: DEFAULT_ADAPTER, } -const HANDLE_BASE_PX = 10 -const CORNER_BASE_PX = 25 +// Handle dot diameter in *screen* px, stepped across 3 zoom ranges so the dots +// stay stable instead of rescaling continuously with zoom. +function handleScreenPx(scale: number): number { + if (scale < 0.5) return 8 + if (scale < 2) return 11 + return 14 +} +const HANDLE_HIT_SLOP_MOUSE = 3 +const HANDLE_HIT_SLOP_TOUCH = 8 const MIN_SCALE_DIMENSION = 20 const SELECTION_PADDING = 5 @@ -324,6 +331,10 @@ export default class SelectionController { this._bringToFront() this.ui.visible = true + // Per-shape `.dragger-picker { cursor: pointer }` (common.css) sits on + // the SVG node directly under the cursor and beats the root's inline + // cursor. This class lets CSS show `move` over the selected shape body. + this.domElement.classList.add('shape-selected') this.syncToTarget() this.two.update() @@ -345,6 +356,7 @@ export default class SelectionController { this.currentTextChild = null this.currentTextLayer = null this.ui.visible = false + this.domElement.classList.remove('shape-selected') this.domElement.style.cursor = '' this.two.update() this.callbacks.onDeselect() @@ -363,7 +375,10 @@ export default class SelectionController { const scale = this.zui.scale || 1 this.box.linewidth = 1.5 / scale - const handleSize = Math.min(HANDLE_BASE_PX / scale, HANDLE_BASE_PX * 1.25) + // Two.Points render with sizeAttenuation=false, so `size` is already a + // screen-px value (the renderer divides out the scene scale). Set it + // directly — no /scale — else the dots inflate when zoomed out. + const handleSize = handleScreenPx(scale) this.endpoints.size = handleSize this.midEndpoints.size = handleSize @@ -401,23 +416,26 @@ export default class SelectionController { const surface = this.zui.clientToSurface(clientX, clientY) const scale = this.zui.scale || 1 - const rotateLimit = HANDLE_BASE_PX / scale - const scaleLimit = CORNER_BASE_PX / scale + // Grab radius is tied to the visible dot (half its diameter) plus a few + // px of slop, so resize only fires on the dot — not in a bloated zone + // around it. Constant in screen px at any zoom. + const hitRadiusPx = handleScreenPx(scale) / 2 + this._hitSlopPx() + const hitLimit = hitRadiusPx / scale const isRect = this.currentGroup?.elementData?.componentType === 'rectangle' if (isRect) { - const midEdge = this._atMidEdge(surface, scaleLimit) + const midEdge = this._atMidEdge(surface, hitLimit) if (midEdge) return { mode: 'scale', corner: midEdge } } - const corner = this._atCorner(surface, scaleLimit) + const corner = this._atCorner(surface, hitLimit) if (!corner) return null const isOnInnerRing = this._withinCornerRadius( surface, corner, - rotateLimit + hitLimit ) const mode = isOnInnerRing && this.rotationEnabled ? 'rotate' : 'scale' return { mode, corner } @@ -482,6 +500,31 @@ export default class SelectionController { return distSq(point.x, point.y, p.x, p.y) < limit * limit } + // Touch arrives as synthetic mouse events, so the device pointer type is the + // only signal available — coarse (finger) pointers get a more forgiving slop. + private _hitSlopPx(): number { + const coarse = + typeof window !== 'undefined' && + window.matchMedia?.('(pointer: coarse)')?.matches + return coarse ? HANDLE_HIT_SLOP_TOUCH : HANDLE_HIT_SLOP_MOUSE + } + + // Is the surface point inside the selection box body (the drag zone)? Uses + // the same rotation convention as _vertexToSurface, derotated into box space. + private _withinBody(point: { x: number; y: number }): boolean { + const rot = this.ui.rotation || 0 + const dx = point.x - this.ui.position.x + const dy = point.y - this.ui.position.y + const cos = Math.cos(-rot) + const sin = Math.sin(-rot) + const lx = dx * cos - dy * sin + const ly = dx * sin + dy * cos + return ( + Math.abs(lx) <= this.box.width / 2 && + Math.abs(ly) <= this.box.height / 2 + ) + } + // ---------- Interaction lifecycle ---------- beginInteraction(e: MouseEvent, hit: HitResult | null): boolean { @@ -560,7 +603,15 @@ export default class SelectionController { if (this.interaction) return const hit = this.hitTest(ev.clientX, ev.clientY) if (!hit) { - this.domElement.style.cursor = '' + // Over the shape body → move (4-way arrow drag cue); empty + // canvas → default. + const surface = this.zui.clientToSurface( + ev.clientX, + ev.clientY + ) + this.domElement.style.cursor = this._withinBody(surface) + ? 'move' + : '' return } if (hit.mode === 'rotate') { diff --git a/src/common.css b/src/common.css index 9ce773c..feb048a 100644 --- a/src/common.css +++ b/src/common.css @@ -17,6 +17,14 @@ cursor: pointer; } +/* A selected shape's body is a drag zone — show the 4-way move cursor over it. + More specific than the bare .dragger-picker rule, so it wins; draw/pan modes + still override via their !important rules. Resize handles aren't + .dragger-picker, so they keep the directional resize cursor from the root. */ +.shape-selected .dragger-picker { + cursor: move; +} + /* While a draw mode (pencil/arrow/rubber) is active, the canvas-wide crosshair cursor must not be overridden by per-shape rules. */ .draw-mode-active .dragger-picker { diff --git a/src/components/sidebar/menuDrawer.tsx b/src/components/sidebar/menuDrawer.tsx index 5f740ff..a7bc092 100644 --- a/src/components/sidebar/menuDrawer.tsx +++ b/src/components/sidebar/menuDrawer.tsx @@ -3,6 +3,7 @@ import type { ReactElement } from 'react' import { Link } from 'react-router-dom' import routes from '../../routes' import { useBoardContext } from '../../views/Board/boardContext' +import { downloadViewportAsImage } from '../../utils/exportViewport' import Modal from '../common/modal' import Button from '../common/button' @@ -63,10 +64,36 @@ const TrashIcon = (): ReactElement => ( ) +const DownloadIcon = (): ReactElement => ( + + + + +) + const MenuDrawer = (): ReactElement => { const refNode = useRef(null) const [showMenu, setShowMenu] = useState(false) const [showConfirm, setShowConfirm] = useState(false) + const [isExporting, setIsExporting] = useState(false) const { clearBoard } = useBoardContext() useEffect(() => { @@ -85,6 +112,18 @@ const MenuDrawer = (): ReactElement => { setShowConfirm(true) } + const handleDownloadClick = async (): Promise => { + setShowMenu(false) + try { + setIsExporting(true) + await downloadViewportAsImage() + } catch (err) { + console.error('Failed to export viewport as image', err) + } finally { + setIsExporting(false) + } + } + const handleConfirmClear = (): void => { clearBoard() setShowConfirm(false) @@ -230,6 +269,25 @@ const MenuDrawer = (): ReactElement => {
+ + +
+