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 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..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&display=swap"
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"
rel="stylesheet"
/>
<title>Craftbase - minimal whiteboard for builders</title>
Expand Down
9 changes: 8 additions & 1 deletion src/App.css
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
:root {
--font-ui: 'Geist', system-ui, sans-serif;
--font-display: 'Fraunces', Georgia, serif;
--font-sketch: 'Caveat', cursive;
--font-sketch: 'Caveat Brush';
--font-mono: 'Geist Mono', monospace;
--font-caveat-brush: 'Caveat Brush', cursive;

--color-canvas: #f5f0e8;
--color-sidebar: #ede8dc;
Expand All @@ -18,6 +19,12 @@
--color-border-card: #c4b89a;
}

.caveat-brush-regular {
font-family: 'Caveat Brush', cursive;
font-weight: 400;
font-style: normal;
}

body {
margin: 0;
font-family: var(--font-ui);
Expand Down
107 changes: 107 additions & 0 deletions src/components/canvasContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react'
import type { ReactElement } from 'react'

import Portal from './common/portal'

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

const MENU_WIDTH = 220
const MENU_MARGIN = 8

/**
* 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.
*/
const CanvasContextMenu = ({
x,
y,
onClose,
onExportSvg,
}: CanvasContextMenuProps): ReactElement => {
const refNode = useRef<HTMLDivElement | null>(null)
const [height, setHeight] = useState(0)

useEffect(() => {
if (refNode.current) setHeight(refNode.current.offsetHeight)
}, [])

useEffect(() => {
const handleClick = (e: MouseEvent): void => {
if (refNode.current?.contains(e.target as Node)) return
onClose()
}
const handleKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClick, false)
document.addEventListener('keydown', handleKey, false)
return (): void => {
document.removeEventListener('mousedown', handleClick, false)
document.removeEventListener('keydown', handleKey, false)
}
}, [onClose])

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

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),
top: Math.max(MENU_MARGIN, top),
width: MENU_WIDTH,
}}
>
<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"
>
<div className="flex items-center gap-2.5">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 1v8M7 9L4.5 6.5M7 9l2.5-2.5"
stroke="#8C7E6A"
strokeWidth="1.1"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 10v1.5a1 1 0 001 1h8a1 1 0 001-1V10"
stroke="#8C7E6A"
strokeWidth="1.1"
strokeLinecap="round"
/>
</svg>
Export selection as SVG
</div>
<span className="text-[10px] text-ink-muted tracking-wide">
⌘⇧D
</span>
</button>
</div>
</Portal>
)
}

export default CanvasContextMenu
7 changes: 6 additions & 1 deletion src/components/elements/circle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ function Circle(props: ElementProps): ReactElement {
})
const { group, circle } = elementFactory.createElement()
group.elementData = { ...props.itemData, ...props }
circle.opacity = props.metadata?.opacity ?? 1
const opacityValue = props.metadata?.opacity ?? 1

if (props.parentGroup) {
const parentGroup = props.parentGroup
circle.opacity = opacityValue
circle.translation.x = props.properties.x
circle.translation.y = props.properties.y
parentGroup.add(circle)
Expand All @@ -57,6 +58,10 @@ function Circle(props: ElementProps): ReactElement {
meta
)

// Group-level opacity so shape + embedded text dim uniformly and
// actually repaint (see rectangle.tsx for the unshift rationale).
group.opacity = opacityValue

two.update()

const groupEl = document.getElementById(group.id)
Expand Down
7 changes: 6 additions & 1 deletion src/components/elements/diamond.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ function Diamond(props: ElementProps): ReactElement {
})
const { group, diamond } = elementFactory.createElement()
group.elementData = { ...props.itemData, ...props }
diamond.opacity = props.metadata?.opacity ?? 1
const opacityValue = props.metadata?.opacity ?? 1

if (props.parentGroup) {
const parentGroup = props.parentGroup
diamond.opacity = opacityValue
parentGroup.add(diamond)
two.update()
} else {
Expand All @@ -50,6 +51,10 @@ function Diamond(props: ElementProps): ReactElement {
meta
)

// Group-level opacity so shape + embedded text dim uniformly and
// actually repaint (see rectangle.tsx for the unshift rationale).
group.opacity = opacityValue

two.update()

const groupEl = document.getElementById(group.id)
Expand Down
10 changes: 7 additions & 3 deletions src/components/elements/geoText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import {
import { lineHeightFor } from '../../utils/textLayout'
import { useMediaQueryUtils } from '../../constants/exportHooks'
import { computeCounterScale } from '../../utils/counterScale'
import { DEFAULT_GEO_RESIST } from '../../constants/misc'
import {
DEFAULT_GEO_RESIST,
DEFAULT_TEXT_FONT_FAMILY,
} from '../../constants/misc'

// GeoText is a clone of NewText (it reuses the same NewTextFactory for
// rendering) with one extra behavior: like a point pin, the whole group is
Expand Down Expand Up @@ -367,7 +370,7 @@ function GeoText(props: ElementProps): ReactElement {
input.style.padding = `${vertPad}px 8px`
input.style.color = twoText.fill || '#3A342C'
input.style.fontSize = `${cssFontSize}px`
input.style.fontFamily = twoText.family || 'Caveat'
input.style.fontFamily = twoText.family || DEFAULT_TEXT_FONT_FAMILY
input.style.fontWeight = twoText.weight || 'normal'
input.style.lineHeight = `${lineH}px`
input.style.letterSpacing = '0px'
Expand All @@ -390,7 +393,8 @@ function GeoText(props: ElementProps): ReactElement {
measureSpan.style.visibility = 'hidden'
measureSpan.style.whiteSpace = 'pre'
measureSpan.style.fontSize = `${cssFontSize}px`
measureSpan.style.fontFamily = twoText.family || 'Caveat'
measureSpan.style.fontFamily =
twoText.family || DEFAULT_TEXT_FONT_FAMILY
measureSpan.style.fontWeight = twoText.weight || 'normal'
measureSpan.style.lineHeight = `${lineH}px`
measureSpan.style.letterSpacing = '0px'
Expand Down
15 changes: 13 additions & 2 deletions src/components/elements/groupobject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Two from 'two.js'
import { useBoardContext } from '../../views/Board/boardContext'
import getEditComponents from '../utils/editWrapper'
import { elementOnBlurHandler } from '../../utils/misc'
import { DEFAULT_TEXT_FONT_FAMILY } from '../../constants/misc'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ElementProps = any
Expand Down Expand Up @@ -61,7 +62,15 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement {
if (!element.elementData) return
if (childrenIdsOfTheGroup.includes(element.elementData.id)) {
foundOriginalCount++
element.opacity = 1
// Restore the element's own (group-level) opacity rather than
// forcing 1 — it was hidden at 0 while the group was selected,
// and per-element opacity now lives on the group. metadata may
// be a pencil vertex array, so guard the `.opacity` read.
const elMeta = element.elementData.metadata
element.opacity =
elMeta && !Array.isArray(elMeta)
? (elMeta.opacity ?? 1)
: 1

if (!groupMoved) {
return
Expand Down Expand Up @@ -369,7 +378,9 @@ function GroupedObjectWrapper(props: ElementProps): ReactElement {
twoText.alignment = 'center'
twoText.baseline = meta.textBaseLine || 'middle'
twoText.family =
meta.textFontFamily || meta.textFamily || 'Caveat'
meta.textFontFamily ||
meta.textFamily ||
DEFAULT_TEXT_FONT_FAMILY
coreObject.add(twoText)
}

Expand Down
31 changes: 29 additions & 2 deletions src/components/elements/newText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
MOBILE_TEXT_SIZES_OBJECT,
} from '../../utils/constants'
import { lineHeightFor } from '../../utils/textLayout'
import { htmlToBulletText } from '../../utils/htmlToBulletText'
import { DEFAULT_TEXT_FONT_FAMILY } from '../../constants/misc'
import { useMediaQueryUtils } from '../../constants/exportHooks'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -337,7 +339,7 @@ function NewText(props: ElementProps): ReactElement {
input.style.padding = `${vertPad}px 8px`
input.style.color = twoText.fill || '#3A342C'
input.style.fontSize = `${cssFontSize}px`
input.style.fontFamily = twoText.family || 'Caveat'
input.style.fontFamily = twoText.family || DEFAULT_TEXT_FONT_FAMILY
input.style.fontWeight = twoText.weight || 'normal'
input.style.lineHeight = `${lineH}px`
input.style.letterSpacing = '0px'
Expand All @@ -360,7 +362,8 @@ function NewText(props: ElementProps): ReactElement {
measureSpan.style.visibility = 'hidden'
measureSpan.style.whiteSpace = 'pre'
measureSpan.style.fontSize = `${cssFontSize}px`
measureSpan.style.fontFamily = twoText.family || 'Caveat'
measureSpan.style.fontFamily =
twoText.family || DEFAULT_TEXT_FONT_FAMILY
measureSpan.style.fontWeight = twoText.weight || 'normal'
measureSpan.style.lineHeight = `${lineH}px`
measureSpan.style.letterSpacing = '0px'
Expand Down Expand Up @@ -395,6 +398,30 @@ function NewText(props: ElementProps): ReactElement {

input.addEventListener('input', autoSizeAndCenter)

// Pasting a bulleted list from a rich-text source (Docs, Notion,
// Notes) into this plain textarea would otherwise drop the bullet
// markers — the source's `text/plain` projection omits them. Read
// the `text/html` flavor and rebuild `• `-prefixed lines so list
// structure survives the paste.
input.addEventListener('paste', (event: ClipboardEvent) => {
const html = event.clipboardData?.getData('text/html')
if (!html) return
const converted = htmlToBulletText(html)
if (converted == null) return

event.preventDefault()
const start = input.selectionStart ?? input.value.length
const end = input.selectionEnd ?? input.value.length
input.value =
input.value.slice(0, start) +
converted +
input.value.slice(end)
const caret = start + converted.length
input.selectionStart = caret
input.selectionEnd = caret
autoSizeAndCenter()
})

input.onfocus = function (): void {
const bRect = blockRect()
selectorInstance.update(
Expand Down
9 changes: 8 additions & 1 deletion src/components/elements/rectangle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ function Rectangle(props: ElementProps): ReactElement {
})
const { group, rectangle } = elementFactory.createElement()
group.elementData = { ...props.itemData, ...props }
rectangle.opacity = props.metadata?.opacity ?? 1
const opacityValue = props.metadata?.opacity ?? 1

if (props.parentGroup) {
const parentGroup = props.parentGroup
rectangle.opacity = opacityValue
parentGroup.add(rectangle)
two.update()
} else {
Expand All @@ -50,6 +51,12 @@ function Rectangle(props: ElementProps): ReactElement {
meta
)

// Apply opacity at the group level so the shape and any embedded
// text dim uniformly, and so it actually repaints (the rounded-rect
// path is double-referenced in group.children via the unshift above,
// which leaves leaf-level opacity flags unprocessed on render).
group.opacity = opacityValue

two.update()

const groupEl = document.getElementById(group.id)
Expand Down
18 changes: 9 additions & 9 deletions src/components/sidebar/elementProperties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ const FontFamilyRow = ({
const families = [
{ label: 'Caveat', family: 'Caveat' },
{ label: 'Geist', family: 'Geist' },
{ label: 'Caveat Brush', family: 'Caveat Brush' },
]
return (
<div className="pt-3 px-2">
Expand Down Expand Up @@ -516,7 +517,7 @@ const ElementPropertiesToolbar = () => {
const sections = SETS[setKey as keyof typeof SETS]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handle =
(key: string) =>
(key: string, opts?: { preview?: boolean }) =>
(val: any): void => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValues((prev: any) => ({ ...prev, [key]: val }))
Expand All @@ -533,13 +534,13 @@ const ElementPropertiesToolbar = () => {
? entry.mobileValue
: entry.value
: val
applyGroupProperty?.(key, numeric)
applyGroupProperty?.(key, numeric, opts)
} else {
applyGroupProperty?.(key, val)
applyGroupProperty?.(key, val, opts)
}
return
}
applyProperty?.(key, val)
applyProperty?.(key, val, opts)
}

return (
Expand Down Expand Up @@ -652,11 +653,10 @@ const ElementPropertiesToolbar = () => {
<OpacitySlider
currentOpacity={values.opacity}
handleOnDrag={(arr): void =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValues((prev: any) => ({
...prev,
opacity: arr[0],
}))
// Live preview while dragging: applies to the scene
// only (no store/history write) so the element fades
// in real time. The release (handleOnChange) commits.
handle('opacity', { preview: true })(arr[0])
}
handleOnChange={(arr) => handle('opacity')(arr[0])}
/>
Expand Down
Loading
Loading