Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.7.6
0.7.7
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "craftbase",
"version": "0.7.6",
"version": "0.7.7",
"private": true,
"main": "src/lib.ts",
"module": "src/lib.ts",
Expand Down
69 changes: 60 additions & 9 deletions src/canvas/selectionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,15 @@ const SHAPE_ADAPTERS: Record<string, ShapeAdapter> = {
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

Expand Down Expand Up @@ -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()

Expand All @@ -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()
Expand All @@ -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

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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') {
Expand Down
8 changes: 8 additions & 0 deletions src/common.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions src/components/sidebar/menuDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -63,10 +64,36 @@ const TrashIcon = (): ReactElement => (
</svg>
)

const DownloadIcon = (): ReactElement => (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 1.5v7M4 5.5L7 8.5l3-3"
stroke="#8C7E6A"
strokeWidth="1.1"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 9.5v1.5a1 1 0 001 1h8a1 1 0 001-1V9.5"
stroke="#8C7E6A"
strokeWidth="1.1"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)

const MenuDrawer = (): ReactElement => {
const refNode = useRef<HTMLDivElement | null>(null)
const [showMenu, setShowMenu] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const { clearBoard } = useBoardContext()

useEffect(() => {
Expand All @@ -85,6 +112,18 @@ const MenuDrawer = (): ReactElement => {
setShowConfirm(true)
}

const handleDownloadClick = async (): Promise<void> => {
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)
Expand Down Expand Up @@ -230,6 +269,25 @@ const MenuDrawer = (): ReactElement => {

<div className="h-px bg-border-panel mx-2 mt-1 mb-1" />

<button
className="flex items-center gap-2.5 px-3 py-2 mx-1 text-sm text-ink-mid
hover:bg-accent/30 rounded cursor-pointer
transition-colors ease-in-out duration-150
disabled:opacity-50 disabled:cursor-default"
style={{ width: 'calc(100% - 8px)' }}
onClick={handleDownloadClick}
disabled={isExporting}
>
<DownloadIcon />
<span>
{isExporting
? 'Preparing image…'
: 'Download as image'}
</span>
</button>

<div className="h-px bg-border-panel mx-2 mt-1 mb-1" />

<button
className="flex items-center gap-2.5 px-3 py-2 mx-1 text-sm text-red-500
hover:bg-red-500/10 rounded cursor-pointer
Expand Down
21 changes: 21 additions & 0 deletions src/newCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ type SetOnGroupHandlerFn = (obj: any) => void
*/

let isDrawing: boolean = false
// True only while a pencil stroke is mid-flight (mousedown → mouseup). An
// in-progress stroke isn't committed to the history log until mouseup, so
// undo/redo must be blocked during this window — otherwise Ctrl+Z would pop the
// previously committed element and the redo stack gets clobbered when the
// current stroke commits, losing that earlier element for good.
let isPencilStrokeActive: boolean = false
let defaultLinewidthValue: number = 1
let defaultStrokeTypeValue: string | null = null
let defaultStrokeColorValue: string = PENCIL_DEFAULT_COLOR
Expand Down Expand Up @@ -1283,6 +1289,10 @@ function addZUI(
domElement.addEventListener('mousemove', mousemove, false)
domElement.addEventListener('mouseup', mouseup, false)

// Stroke is now mid-flight — block undo/redo until mouseup
// commits it (see isPencilStrokeActive declaration).
isPencilStrokeActive = true

// Single growing curved path for live preview — matches the
// final factory render so there's no visual jump on mouseup.
pencilGroup = two.makeGroup()
Expand Down Expand Up @@ -2064,6 +2074,9 @@ function addZUI(
domElement.removeEventListener('mouseup', mouseup, false)
break
case SCENARIO_PENCIL_MODE: {
// Gesture is over — re-enable undo/redo before the stroke is
// committed to the store/history just below.
isPencilStrokeActive = false
const capturedPencilGroup = pencilGroup
pencilGroup = null
pencilPath = null
Expand Down Expand Up @@ -2858,6 +2871,9 @@ const Canvas: React.FC<CanvasProps> = (props) => {

useEffect(() => {
isPencilModeRef.current = props.isPencilMode
// Toggling the pencil tool means no stroke is in flight — clear the
// guard so a missed mouseup can't leave undo blocked indefinitely.
isPencilStrokeActive = false
const root = document.getElementById('main-two-root')
if (props.isPencilMode === true) {
isDrawing = true
Expand Down Expand Up @@ -3011,6 +3027,11 @@ const Canvas: React.FC<CanvasProps> = (props) => {
const onUndoRedoKeyDown = (evt: KeyboardEvent) => {
if (evt.key.toLowerCase() === 'z' && (evt.ctrlKey || evt.metaKey)) {
evt.preventDefault()
// Don't undo/redo mid-stroke — the in-progress pencil stroke
// isn't in history yet, so undoing here would pop the wrong
// (previously committed) element. User must finish the stroke
// first. See isPencilStrokeActive.
if (isPencilStrokeActive) return
if (evt.shiftKey) {
redoLastAction()
} else {
Expand Down
Loading
Loading