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.9
0.7.10
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "craftbase",
"version": "0.7.9",
"version": "0.7.10",
"private": true,
"main": "src/lib.ts",
"module": "src/lib.ts",
Expand Down Expand Up @@ -55,12 +55,15 @@
},
"resolutions": {
"micromatch": ">=4.0.8",
"form-data": ">=3.0.4",
"form-data": "^4.0.6",
"brace-expansion": ">=5.0.6",
"lodash": ">=4.18.0",
"postcss": ">=8.5.10",
"shell-quote": ">=1.8.4",
"vite": "~6.4.3"
"vite": "~6.4.3",
"js-yaml": "^4.2.0",
"@babel/core": "^7.29.6",
"subscriptions-transport-ws/ws": "^7.5.11"
},
"eslintConfig": {
"extends": "react-app/base"
Expand Down
4 changes: 4 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ code {
#main-two-root svg {
background-color: #f5f0e8;
background-image: radial-gradient(circle, #c4b89a 1px, transparent 1px);
/* First-paint default. background-size + background-position are driven live
by syncBackgroundToCamera() in newCanvas.tsx so the dot grid scales/pans
with the Two.js camera. Base tile (24px) is BG_TILE_BASE there — keep in
sync. */
background-size: 24px 24px;
z-index: -1;
}
Expand Down
8 changes: 6 additions & 2 deletions src/components/ZoomControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { useBoardContext } from '../views/Board/boardContext'
import zoomInIcon from '../assets/zoom-in.svg'
import zoomOutIcon from '../assets/zoom-out.svg'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ZuiWrapperLike = { zui: any } | null
type ZuiWrapperLike = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
zui: any
syncBackgroundToCamera?: () => void
} | null

const ZoomControls = (): ReactElement => {
const { zuiInBoard, twoJSInstance, scaleToDisplay } = useBoardContext()
Expand All @@ -31,6 +34,7 @@ const ZoomControls = (): ReactElement => {
if (!zui || !twoJSInstance) return
zui.zui.zoomBy(delta, window.innerWidth / 2, window.innerHeight / 2)
twoJSInstance.update()
zui.syncBackgroundToCamera?.()
window.dispatchEvent(
new CustomEvent('zoomChanged', {
detail: { scale: zui.zui.scale },
Expand Down
23 changes: 20 additions & 3 deletions src/components/sidebar/shareLinkPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@ const ShareLinkPopup = (): ReactElement => {
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [isPersisting, setIsPersisting] = useState(false)
const [shareUrl, setShareUrl] = useState<string | null>(null)
const { isPersisted, persistBoard, backgroundBoardId, stateRefForComponentStore } =
useBoardContext()
const {
isPersisted,
persistBoard,
backgroundBoardId,
stateRefForComponentStore,
twoJSInstance,
} = useBoardContext()

// Whether there's anything to share. Gate on the actual component store,
// NOT backgroundBoardId: that id is only minted by ensureBackgroundBoard()
Expand Down Expand Up @@ -57,10 +62,22 @@ const ShareLinkPopup = (): ReactElement => {
const serverBoardId = await persistBoard()
await updateBoardVisibility({ variables: { id: serverBoardId } })
const url = `${window.location.origin}/board/${serverBoardId}`
// The shared/copied link stays clean (no params) — params only ride
// on the auto-opened tab to hand off the current '/' viewport.
setShareUrl(url)
setShowConfirmModal(false)
setShowLink(true)
window.open(url, '_blank', 'noopener,noreferrer')
// Carry the live '/' viewport (pan + zoom) to the freshly-created
// board via query params so the opened tab lands on the same view
// instead of the origin. Read the live scene rather than the
// debounced localStorage entry so the last pan before clicking Share
// is included. The land side (newCanvas) consumes these once, seeds
// this board's viewport localStorage key(s), then strips the params.
const scene = twoJSInstance?.scene
const openUrl = scene
? `${url}?vx=${scene.translation.x}&vy=${scene.translation.y}&vs=${scene.scale}`
: url
window.open(openUrl, '_blank', 'noopener,noreferrer')
} finally {
setIsPersisting(false)
}
Expand Down
93 changes: 92 additions & 1 deletion src/newCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,42 @@ function addZUI(
zui.clientToSurface(e.clientX, e.clientY)
zui.addLimits(0.06, 8)

// Parchment dot-grid camera sync. The grid is a CSS radial-gradient painted
// on the renderer's SVG element, which lives in screen space (Two.js
// transforms the scene group inside it, never the SVG itself). To make the
// grid feel glued to the canvas we mirror the camera onto the CSS background:
// scale the tile by the zoom and offset it by the scene translation. CSS
// tiling makes it an infinite grid for free, and the dots re-rasterize sharp
// at every zoom. Base tile is 24px (kept in sync with App.css).
const BG_TILE_BASE = 24
// Keep the on-screen dot spacing inside a comfortable band by stepping the
// tile through power-of-2 "octaves". A plain `BG_TILE_BASE * scale` shrinks
// the tile as you zoom out, crowding the dots into a dense mush below ~50%
// (and into a sparse field when zoomed far in). Doubling/halving the tile at
// the band edges keeps apparent spacing roughly constant. MAX must be 2*MIN
// so each octave wrap lands back inside the band.
const BG_TILE_MIN = 16
const BG_TILE_MAX = 32
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let bgSvgEl: any = null
const syncBackgroundToCamera = () => {
if (!bgSvgEl) {
const root = document.getElementById('main-two-root')
bgSvgEl = root?.querySelector('svg') || null
}
if (!bgSvgEl) return
const scale = two.scene.scale || 1
const tx = two.scene.translation.x
const ty = two.scene.translation.y
let size = BG_TILE_BASE * scale
if (size > 0) {
while (size < BG_TILE_MIN) size *= 2
while (size > BG_TILE_MAX) size /= 2
}
bgSvgEl.style.backgroundSize = `${size}px ${size}px`
bgSvgEl.style.backgroundPosition = `${tx}px ${ty}px`
}

const setRootCursor = (cursor: string) => {
const root = document.getElementById('main-two-root')
if (root) root.style.cursor = cursor
Expand Down Expand Up @@ -555,9 +591,14 @@ function addZUI(
},
commit: (id, patch) => {
updateComponentBulkPropertiesInLocalStore(id, patch)
const g = selectionController.currentGroup
// Keep elementData (read by copy/paste and other consumers) in step
// with the store + rendered shape after a resize — otherwise a clone
// reads stale draw-time width/height. Mirrors the font-resize
// metadata sync in selectionController's resize-end path.
if (g?.elementData) Object.assign(g.elementData, patch)
// Resize ended — persist any connectors whose ports tracked the
// shape so their new tail/head survive a reload.
const g = selectionController.currentGroup
if (g) persistBoundArrows(g)
},
onTransform: (group) => {
Expand Down Expand Up @@ -2240,6 +2281,7 @@ function addZUI(
if (dx !== 0 || dy !== 0) {
zui.translateSurface(dx, dy)
two.update()
syncBackgroundToCamera()
onCameraChangeRef?.current?.({
scale: two.scene.scale,
tx: two.scene.translation.x,
Expand Down Expand Up @@ -3249,6 +3291,7 @@ function addZUI(
}

two.update()
syncBackgroundToCamera()

onCameraChangeRef?.current?.({
scale: two.scene.scale,
Expand Down Expand Up @@ -3378,6 +3421,7 @@ function addZUI(
if (dx !== 0 || dy !== 0) {
zui.translateSurface(dx, dy)
two.update()
syncBackgroundToCamera()
onCameraChangeRef?.current?.({
scale: two.scene.scale,
tx: two.scene.translation.x,
Expand Down Expand Up @@ -3581,6 +3625,7 @@ function addZUI(
distance = newDist

two.update()
syncBackgroundToCamera()

onCameraChangeRef?.current?.({
scale: two.scene.scale,
Expand All @@ -3591,6 +3636,7 @@ function addZUI(

return {
zui,
syncBackgroundToCamera,
mousemove,
resetDragState: () => {
dragging = false
Expand Down Expand Up @@ -3761,6 +3807,47 @@ const Canvas: React.FC<CanvasProps> = (props) => {
}
}

// First-land seed: a board opened from Share carries the originating
// '/' viewport (pan + zoom) in the URL (vx/vy/vs). Clone it into this
// board's viewport localStorage key(s) so restoreViewport below lands
// on the same view instead of the origin. Seed both desktop+mobile keys
// (sharer and opener may be on different devices), then strip the params
// so a later reload uses the save-as-you-go value rather than the frozen
// param — i.e. subsequent reloads honour the last pan/zoom for this board.
if (props.boardId) {
const params = new URLSearchParams(window.location.search)
if (params.has('vx') && params.has('vy') && params.has('vs')) {
const tx = Number(params.get('vx'))
const ty = Number(params.get('vy'))
const scale = Number(params.get('vs'))
// Guard against malformed/tampered params — a NaN here would
// feed zoomSet/translateSurface and corrupt the scene.
if (
Number.isFinite(tx) &&
Number.isFinite(ty) &&
Number.isFinite(scale) &&
scale > 0
) {
const seeded = JSON.stringify({
tx,
ty,
scale,
savedAt: Date.now(),
})
localStorage.setItem(
`${VIEWPORT_KEY_PREFIX}${props.boardId}`,
seeded
)
localStorage.setItem(
`${MOBILE_VIEWPORT_KEY_PREFIX}${props.boardId}`,
seeded
)
}
// Strip the params regardless so a reload never re-seeds.
window.history.replaceState({}, '', window.location.pathname)
}
}

if (isMobile && props.boardId) {
restoreViewport(`${MOBILE_VIEWPORT_KEY_PREFIX}${props.boardId}`)
}
Expand All @@ -3769,6 +3856,10 @@ const Canvas: React.FC<CanvasProps> = (props) => {
restoreViewport(`${VIEWPORT_KEY_PREFIX}${props.boardId}`)
}

// Seed the parchment grid from the restored (or default) camera so it
// lands aligned on first paint, not just on the first pan/zoom.
zui_instance.syncBackgroundToCamera()

onCameraChangeRef.current?.({
scale: two.scene.scale as number,
tx: two.scene.translation.x,
Expand Down
Loading
Loading