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
5 changes: 3 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Reusable React UI components.
- `userDetailsPopup.tsx` - User information popup
- `sidebar.css` - Sidebar styles

**Element defaults vs. selected-shape edits:** `src/utils/applyProperty.ts` (`createApplyProperty`) is the single mutation path behind `elementProperties.tsx`. Every property change (1) updates the matching default via `useElementDefaults` setters, then (2) if a shape is selected, applies the same change to that shape. So editing a property with nothing selected just sets the default; editing with a shape selected sets both. Defaults store `null` for `strokeType: 'solid'` (matching what `primary.tsx` feeds new shapes); DB rows store the literal `'solid'`/`'dashed'`/`'dotted'`.
**Element defaults vs. selected-shape edits:** `src/utils/applyProperty.ts` (`createApplyProperty`) is the single mutation path behind `elementProperties.tsx`. Every property change (1) updates the matching default via `useElementDefaults` setters, then (2) if a shape is selected, applies the same change to that shape. So editing a property with nothing selected just sets the default; editing with a shape selected sets both. Defaults store `null` for `strokeType: 'solid'` (matching what `primary.tsx` feeds new shapes); DB rows store the literal `'solid'`/`'dashed'`/`'dotted'`.

- **`common/`**: Shared utility components
- `button.tsx` - Base button component
Expand Down Expand Up @@ -302,7 +302,8 @@ See detailed notes in `.claude/context/` for feature-specific implementation det
- `.claude/context/floating-toolbar.md` - Floating toolbar activation and structure
- `.claude/context/undo-history.md` - Undo/history stack: action entry shapes, `recordToHistoryLog`, and `undoLastAction()` as the canonical rollback for any failed mutation
- `.claude/context/responsive-design.md` - When to use Tailwind responsive prefixes vs `useMediaQueryUtils` hook; breakpoint values for both; the core decision rule
- `.claude/context/font-guide.md` - Font system: Geist (UI chrome), Fraunces (branding/headings), Caveat (canvas sketch); CSS variables, Tailwind config, and usage rules per area
- `.claude/context/font-guide.md` - Font system: Geist (UI chrome), Fraunces (branding/headings), Caveat Brush (canvas sketch); CSS variables, Tailwind config, and usage rules per area
- `claude/context/reorder.md` - How reording/positioning of elements in Z-Axis (Z-order) works in craftbase

### Component schema (from DB)

Expand Down
26 changes: 26 additions & 0 deletions .claude/context/reorder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## Reorder/Positioning of elements

In 2d space, we need to adjust how elements behave on z-axis. For that we need to reorder children of two group to achieve such expectations.

In craftbase, we have four options

- Bring to front (Brings it to the foremost top of the order, at [N])
- Bring forward (Brings 1 order up , at[current+1])
- Send Backward (Sends 1 order down, at [current-1])
- Send to Back (Sends to last of the order, at [0])

This is being triggered by three inputs from user

- Keyboard shortcuts
- Context Menu (opens on right click)
- Element Properties Toolbar or edit toolbar

Shortcuts are

```
`]` = Bring Forward, `[` = Send Backward, `⌘` + `]` = Bring to Front, `⌘` + `[` = Send to Back (and Ctrl+… on Windows/Linux)
```

## Business logic

We attach a property to each component called `position` which determines its position in Z-Axis or two's scene. The core logic is implemented at `reorderSelected` fn of newCanvas.tsx file .
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Caveat:wght@400&family=Fraunces:ital,opsz,wght@0,9..144,100..900;1,9..144,100..900&family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&family=Caveat+Brush&display=swap"
href="https://fonts.googleapis.com/css2?family=Caveat:wght@700&family=Fraunces:ital,opsz,wght@0,9..144,100..900;1,9..144,100..900&family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&family=Caveat+Brush&display=swap"
rel="stylesheet"
/>
<title>Craftbase - minimal whiteboard for builders</title>
Expand Down
1 change: 1 addition & 0 deletions src/assets/bring-forward.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/bring-to-front.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/cards.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/chevron-right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/layers.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/send-backward.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/send-to-back.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/canvas/selectionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,17 @@ export default class SelectionController {
}
}

/**
* Public re-assert of the selection overlay to the top of the scene.
* Called by the z-order reconcile after it re-sorts element groups, so the
* selection box never gets buried beneath a just-reordered element. No-op
* when nothing is selected.
*/
bringSelectionToFront(): void {
if (!this.currentGroup) return
this._bringToFront()
}

attach(group: GroupLike, shape?: ShapeLike): boolean {
const type = group?.elementData?.componentType
const adapter = SHAPE_ADAPTERS[type]
Expand Down
181 changes: 167 additions & 14 deletions src/components/canvasContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,102 @@
import { useEffect, useRef, useState } from 'react'
import type { ReactElement } from 'react'
import type { ReactElement, FunctionComponent, SVGProps } from 'react'

import Portal from './common/portal'
import { isMac } from '../utils/misc'
import LayersIcon from '../assets/layers.svg?react'
import BringToFrontIcon from '../assets/bring-to-front.svg?react'
import BringForwardIcon from '../assets/bring-forward.svg?react'
import SendBackwardIcon from '../assets/send-backward.svg?react'
import SendToBackIcon from '../assets/send-to-back.svg?react'
import ChevronRightIcon from '../assets/chevron-right.svg?react'

export type ReorderOp = 'front' | 'forward' | 'backward' | 'back'

interface CanvasContextMenuProps {
x: number
y: number
onClose: () => void
onExportSvg: () => void
onReorder: (op: ReorderOp) => void
}

const MENU_WIDTH = 220
const SUBMENU_WIDTH = 212
const MENU_MARGIN = 8

// Shared icon tone — matches the menu's ink-mid text. The source SVGs hardcode
// a blue stroke; SVGR spreads props after the original attrs, so this wins.
const ICON_STROKE = '#8C7E6A'

const itemClass =
`w-full flex items-center justify-between px-3 py-2 mx-0 text-sm text-ink-mid ` +
`hover:bg-accent/30 rounded cursor-pointer transition-colors ease-in-out duration-150`

const shortcutClass = 'text-[10px] text-ink-muted tracking-wide'

// Format a shortcut for the host OS: compact symbols on mac (⌘]), the
// conventional spelled-out form elsewhere (Ctrl+]). Kept in sync with the
// keyboard handler in newCanvas.tsx, which acts on metaKey on mac / ctrlKey on
// the rest. Reorder uses bare brackets for forward/back-one and ⌘/Ctrl+bracket
// for to-front/to-back (no Shift — ⌘⇧[/] are reserved tab-switch on mac Chrome).
export const fmtShortcut = (
key: string,
{ cmd = false, shift = false }: { cmd?: boolean; shift?: boolean } = {}
): string =>
isMac
? `${cmd ? '⌘' : ''}${shift ? '⇧' : ''}${key}`
: `${cmd ? 'Ctrl+' : ''}${shift ? 'Shift+' : ''}${key}`

type ReorderItem = {
op: ReorderOp
label: string
shortcut: string
Icon: FunctionComponent<SVGProps<SVGSVGElement>>
}

const REORDER_ITEMS: ReorderItem[] = [
{
op: 'front',
label: 'Bring to Front',
shortcut: fmtShortcut(']', { cmd: true }),
Icon: BringToFrontIcon,
},
{
op: 'forward',
label: 'Bring Forward',
shortcut: fmtShortcut(']'),
Icon: BringForwardIcon,
},
{
op: 'backward',
label: 'Send Backward',
shortcut: fmtShortcut('['),
Icon: SendBackwardIcon,
},
{
op: 'back',
label: 'Send to Back',
shortcut: fmtShortcut('[', { cmd: true }),
Icon: SendToBackIcon,
},
]

/**
* Small fixed-position menu opened on canvas right-click / two-finger tap.
* Closes on outside click or Escape. Positioned at the cursor and clamped to
* the viewport so it never overflows off-screen.
* the viewport so it never overflows off-screen. The Reorder entry opens a
* flyout submenu to the right (or left, near the screen edge).
*/
const CanvasContextMenu = ({
x,
y,
onClose,
onExportSvg,
onReorder,
}: CanvasContextMenuProps): ReactElement => {
const refNode = useRef<HTMLDivElement | null>(null)
const [height, setHeight] = useState(0)
const [reorderOpen, setReorderOpen] = useState(false)

useEffect(() => {
if (refNode.current) setHeight(refNode.current.offsetHeight)
Expand All @@ -48,28 +119,110 @@ const CanvasContextMenu = ({
}, [onClose])

const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_MARGIN)
const top = Math.min(
y,
window.innerHeight - (height || 60) - MENU_MARGIN
)
const top = Math.min(y, window.innerHeight - (height || 60) - MENU_MARGIN)
const clampedLeft = Math.max(MENU_MARGIN, left)

// Flip the flyout to the left when there isn't room on the right.
const openLeft =
clampedLeft + MENU_WIDTH + SUBMENU_WIDTH + MENU_MARGIN >
window.innerWidth

return (
<Portal>
<div
ref={refNode}
className="fixed z-[100] bg-card-bg border border-border-panel rounded-lg shadow-lg py-1"
style={{
left: Math.max(MENU_MARGIN, left),
left: clampedLeft,
top: Math.max(MENU_MARGIN, top),
width: MENU_WIDTH,
}}
>
<div
className="relative"
onMouseEnter={() => setReorderOpen(true)}
onMouseLeave={() => setReorderOpen(false)}
>
<button
type="button"
onClick={() => setReorderOpen((v) => !v)}
className={itemClass}
>
<div className="flex items-center gap-2.5">
<LayersIcon
width={15}
height={15}
stroke={ICON_STROKE}
strokeWidth={2}
/>
Reorder
</div>
<ChevronRightIcon
width={15}
height={15}
stroke={ICON_STROKE}
strokeWidth={2}
/>
{/* <svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.5 2l3 3-3 3"
stroke={ICON_STROKE}
strokeWidth="1.1"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg> */}
</button>

{reorderOpen && (
<div
className="absolute top-0 bg-card-bg border border-border-panel rounded-lg shadow-lg py-1 z-[101]"
style={{
width: SUBMENU_WIDTH,
...(openLeft
? { right: '100%' }
: { left: '100%' }),
}}
>
{REORDER_ITEMS.map(
({ op, label, shortcut, Icon }) => (
<button
key={op}
type="button"
onClick={() => onReorder(op)}
className={itemClass}
>
<div className="flex items-center gap-2.5">
<Icon
width={15}
height={15}
stroke={ICON_STROKE}
strokeWidth={2}
/>
{label}
</div>
<span className={shortcutClass}>
{shortcut}
</span>
</button>
)
)}
</div>
)}
</div>

<div className="my-1 mx-2 border-t border-border-panel" />

<button
type="button"
onClick={onExportSvg}
className="w-full flex items-center justify-between px-3 py-2 mx-0 text-sm text-ink-mid
hover:bg-accent/30 rounded cursor-pointer
transition-colors ease-in-out duration-150"
className={itemClass}
>
<div className="flex items-center gap-2.5">
<svg
Expand All @@ -81,22 +234,22 @@ const CanvasContextMenu = ({
>
<path
d="M7 1v8M7 9L4.5 6.5M7 9l2.5-2.5"
stroke="#8C7E6A"
stroke={ICON_STROKE}
strokeWidth="1.1"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 10v1.5a1 1 0 001 1h8a1 1 0 001-1V10"
stroke="#8C7E6A"
stroke={ICON_STROKE}
strokeWidth="1.1"
strokeLinecap="round"
/>
</svg>
Export selection as SVG
</div>
<span className="text-[10px] text-ink-muted tracking-wide">
⌘⇧D
<span className={shortcutClass}>
{fmtShortcut('D', { cmd: true, shift: true })}
</span>
</button>
</div>
Expand Down
Loading
Loading