Skip to content
Draft
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
36 changes: 36 additions & 0 deletions backend/test/unit/datatypes/test_ink_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Ink property round-trip coverage."""

from topix.collab.note_to_wire import note_to_wire_node
from topix.datatypes.note.note import Note
from topix.datatypes.note.style import NodeType


def test_ink_note_validates_and_serializes_to_wire() -> None:
"""Persisted freehand payloads survive Note validation and relay output."""
note = Note.model_validate({
"id": "00000000-0000-4000-8000-000000000001",
"graph_uid": "board-1",
"style": {"type": "ink", "stroke_color": "#123456"},
"properties": {
"node_position": {"type": "position", "position": {"x": 10, "y": 20}},
"node_size": {"type": "size", "size": {"width": 30, "height": 40}},
"ink_data": {
"type": "ink",
"version": 1,
"size": 5,
"points": [[0, 0, 0.2], [20, 20, 0.9]],
"outline": [[0, 0], [20, 0], [20, 20]],
"intrinsicWidth": 20,
"intrinsicHeight": 20,
},
},
})

assert note.style.type == NodeType.INK
assert note.properties.ink_data is not None
assert note.properties.ink_data.intrinsic_width == 20

wire = note_to_wire_node(note)
assert wire["type"] == "ink"
assert wire["data"]["styleType"] == NodeType.INK
assert wire["data"]["properties"]["ink_data"]["outline"]
3 changes: 2 additions & 1 deletion backend/topix/collab/note_to_wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"code-sandbox": "code-sandbox",
"widget": "widget",
"mini-app": "mini-app",
"ink": "ink",
}

# Inverse map for the inbound persistence fallback (apply_ops.py). Auto-
Expand All @@ -63,7 +64,7 @@
# IMPORTANT: keep in sync with webui/.../convert/note-to-node.ts
# (`AUTOFIT_DISABLED_TYPES`).
_AUTOFIT_DISABLED_CANVAS_TYPES: frozenset[str] = frozenset({
"folder", "sheet", "code-sandbox", "widget", "mini-app", "document",
"folder", "sheet", "code-sandbox", "widget", "mini-app", "document", "ink",
})


Expand Down
2 changes: 2 additions & 0 deletions backend/topix/datatypes/note/note.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
DataProperty,
IconProperty,
ImageProperty,
InkProperty,
NumberProperty,
PositionProperty,
SizeProperty,
Expand Down Expand Up @@ -71,6 +72,7 @@ class NoteProperties(ResourceProperties):
programming_language: TextProperty = Field(
default_factory=lambda: TextProperty(text="python")
)
ink_data: InkProperty | None = None


class Note(Resource):
Expand Down
1 change: 1 addition & 0 deletions backend/topix/datatypes/note/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class NodeType(StrEnum):
CODE_SANDBOX = "code-sandbox"
WIDGET = "widget"
MINI_APP = "mini-app"
INK = "ink"


class StrokeStyle(StrEnum):
Expand Down
18 changes: 17 additions & 1 deletion backend/topix/datatypes/property.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from enum import IntEnum, StrEnum
from typing import Annotated, Literal, Type

from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field

from topix.agents.datatypes.annotations import SearchResult
from topix.agents.datatypes.reasoning_step import ReasoningStep
Expand Down Expand Up @@ -35,6 +35,7 @@ class PropertyType(StrEnum):
URL = "url"
REASONING = "reasoning"
MULTI_SOURCE = "multi_source"
INK = "ink"


class Property(abc.ABC, BaseModel):
Expand Down Expand Up @@ -225,6 +226,20 @@ class MultiSourceProperty(Property):
sources: list[SearchResult] = []


class InkProperty(Property):
"""Pressure-aware freehand samples and their precomputed render outline."""

model_config = ConfigDict(populate_by_name=True)

type: Literal[PropertyType.INK] = PropertyType.INK
version: Literal[1] = 1
size: float
points: list[tuple[float, float, float]]
outline: list[tuple[float, float]]
intrinsic_width: float = Field(alias="intrinsicWidth")
intrinsic_height: float = Field(alias="intrinsicHeight")


type DataProperty = Annotated[
(
NumberProperty
Expand All @@ -242,6 +257,7 @@ class MultiSourceProperty(Property):
| PositionProperty
| SizeProperty
| ReasoningProperty
| InkProperty
),
Field(discriminator="type")
]
1 change: 1 addition & 0 deletions webui/src/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export {
DrawIcon,
EditIcon,
EditNoteIcon,
EraserIcon,
EllipsisIcon,
ExaBrandIcon,
ExpandIcon,
Expand Down
2 changes: 2 additions & 0 deletions webui/src/components/icons/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
DotsThreeOutlineVerticalIcon,
DownloadSimpleIcon,
EnvelopeIcon,
EraserIcon as EraserGlyphIcon,
EyeIcon,
EyeSlashIcon,
FileCodeIcon as FileCodeGlyphIcon,
Expand Down Expand Up @@ -168,6 +169,7 @@ export const DragGripIcon = createPhosphorIcon(DotsSixVerticalIcon)
export const DrawIcon = createPhosphorIcon(PentagramIcon)
export const EditIcon = createPhosphorIcon(PencilIcon)
export const EditNoteIcon = createPhosphorIcon(PencilIcon)
export const EraserIcon = createPhosphorIcon(EraserGlyphIcon)
export const EllipsisIcon = createPhosphorIcon(DotsThreeGlyphIcon)
export const ExaBrandIcon = createReactIcon(Exa.Color)
export const ExternalLinkIcon = createPhosphorIcon(ArrowSquareOutIcon)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const EXPECTED_CUSTOM_TYPES = [
"mini-app",
"code-sandbox",
"sheet",
"ink",
// No text concept — dbl-click must not open the lib's inline editor.
"icon",
"image",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const CUSTOM_NODE_TYPES: ReadonlySet<string> = new Set([
"code-sandbox",
"widget",
"mini-app",
"ink",
// No text-content concept; double-click should be a no-op rather
// than open an invisible inline text editor.
"icon",
Expand Down
24 changes: 22 additions & 2 deletions webui/src/features/board/harness/canvas/harness-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import { useViewportPersistence } from "./use-viewport-persistence"
import { useTrackBoardCameraMotion } from "./board-camera-motion"
import { useSidebarContentsSync } from "./use-sidebar-contents-sync"
import { HarnessWrapRefProvider } from "./wrap-ref-provider"
import { InkInputLayer } from "../ink/ink-input-layer"


/**
Expand Down Expand Up @@ -114,6 +115,8 @@ export function HarnessCanvas({ local = false }: { local?: boolean } = {}) {
const store = storeRef.current

const tool = useBoardAppStore((s) => s.tool)
const inkColor = useBoardAppStore((s) => s.inkColor)
const inkSize = useBoardAppStore((s) => s.inkSize)
const viewMode = useBoardAppStore((s) => s.viewMode)
const theme = useBoardTheme()
const [ready, setReady] = useState(false)
Expand Down Expand Up @@ -429,7 +432,11 @@ export function HarnessCanvas({ local = false }: { local?: boolean } = {}) {
<HarnessWrapRefProvider value={wrapRef}>
<div
ref={wrapRef}
className="absolute inset-0"
className={`absolute inset-0 ${
tool === "ink" || tool === "eraser"
? "select-none [-webkit-touch-callout:none]"
: ""
}`}
onDragOver={onDragOver}
onDrop={onDrop}
>
Expand All @@ -444,6 +451,16 @@ export function HarnessCanvas({ local = false }: { local?: boolean } = {}) {
onDoubleClick={handleDoubleClick}
onRenderer={handleRenderer}
/>
<InkInputLayer
store={store}
wrapRef={wrapRef}
boardId={boardId}
rootId={rootId}
tool={tool}
canEdit={canEdit}
color={inkColor}
size={inkSize}
/>
<CanvasContextMenu wrapRef={wrapRef} store={store} rendererRef={rendererRef} />
</div>
</HarnessWrapRefProvider>
Expand Down Expand Up @@ -483,12 +500,15 @@ function HarnessCanvasInner({
// already-self-hiding chrome (readonly chip, top-right strip) stays
// managed by their own components.
const presenting = useBoardAppStore((s) => s.presentationMode)
// The overlay owns pen/mouse ink gestures. Let touch continue through the
// harness pan path so one finger moves the board and pinch zoom still works.
const canvasTool = tool === "ink" || tool === "eraser" ? "pan" : tool
return (
<>
{isBoard ? (
<>
<Canvas
tool={tool}
tool={canvasTool}
theme={theme.resolver}
selectionColor={theme.selectionColor}
background={theme.background}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const EXCLUDED_TYPES: ReadonlySet<string> = new Set([
"widget",
"document",
"mini-app",
"ink",
// Icons and images opt out: user-picked color is per-icon, not a
// sticky session preference, and images don't carry colors anyway.
// Without this, dropping an icon/image after styling a rectangle
Expand Down
81 changes: 81 additions & 0 deletions webui/src/features/board/harness/chrome/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import {
ConnectorPathIcon,
CursorSelectIcon,
DiamondShapeIcon,
EraserIcon,
GraphViewIcon,
GridViewIcon,
HandGrabIcon,
HandPanIcon,
LayerStackIcon,
ListViewIcon,
NotepadIcon,
PencilEditIcon,
PresentationIcon,
ShapesMenuIcon,
SquareShapeIcon,
Expand All @@ -28,6 +30,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Separator } from "@/components/ui/separator"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Slider } from "@/components/ui/slider"
import {
Tooltip,
TooltipContent,
Expand Down Expand Up @@ -217,6 +221,10 @@ function FlaredTray({
export function HarnessToolbar({ local = false }: { local?: boolean } = {}) {
const tool = useBoardAppStore((s) => s.tool)
const setTool = useBoardAppStore((s) => s.setTool)
const inkColor = useBoardAppStore((s) => s.inkColor)
const setInkColor = useBoardAppStore((s) => s.setInkColor)
const inkSize = useBoardAppStore((s) => s.inkSize)
const setInkSize = useBoardAppStore((s) => s.setInkSize)
const chromeDialog = useBoardAppStore((s) => s.chromeDialog)
const setChromeDialog = useBoardAppStore((s) => s.setChromeDialog)
const slidesPanelOpen = useBoardAppStore((s) => s.slidesPanelOpen)
Expand Down Expand Up @@ -338,6 +346,79 @@ export function HarnessToolbar({ local = false }: { local?: boolean } = {}) {
<TooltipContent side="bottom" sideOffset={10}>Select</TooltipContent>
</Tooltip>

<div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setTool("ink")}
aria-label="Pen"
aria-pressed={tool === "ink"}
className={tool === "ink" ? activeClass : inactiveClass}
>
<PencilEditIcon className="size-4 shrink-0" weight={tool === "ink" ? "fill" : undefined} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={10}>Pen</TooltipContent>
</Tooltip>
{tool === "ink" && (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Pen settings"
className="ml-0.5 flex size-6 items-center justify-center rounded-md hover:bg-secondary/60"
>
<span
className="size-3 rounded-full border border-foreground/20"
style={{ backgroundColor: inkColor }}
/>
</button>
</PopoverTrigger>
<PopoverContent side="bottom" sideOffset={10} className="w-56 space-y-4">
<label className="flex items-center justify-between gap-3 text-sm">
<span>Color</span>
<input
type="color"
value={inkColor}
onChange={(event) => setInkColor(event.target.value)}
className="h-8 w-12 cursor-pointer rounded border border-border bg-transparent p-0.5"
/>
</label>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Width</span>
<span className="text-muted-foreground">{inkSize}px</span>
</div>
<Slider
min={1}
max={24}
step={1}
value={[inkSize]}
onValueChange={([value]) => setInkSize(value)}
aria-label="Pen width"
/>
</div>
</PopoverContent>
</Popover>
)}
</div>

<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setTool("eraser")}
aria-label="Eraser"
aria-pressed={tool === "eraser"}
className={tool === "eraser" ? activeClass : inactiveClass}
>
<EraserIcon className="size-4 shrink-0" weight={tool === "eraser" ? "fill" : undefined} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={10}>Eraser</TooltipContent>
</Tooltip>

<Separator orientation="vertical" className="hidden md:!h-6 md:block" />

<DropdownMenu
Expand Down
4 changes: 4 additions & 0 deletions webui/src/features/board/harness/convert/node-to-note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const RAD_TO_DEG = 180 / Math.PI
export const nodeToNote = (node: Node): Note => {
const data = (node.data ?? {}) as Partial<NoteNodeData>
const extraProperties = data.properties ?? {}
const relayProperties = extraProperties as typeof extraProperties & {
ink_data?: NoteProperties["inkData"]
}
const groupIds = node.groups as unknown as string[]

const properties: NoteProperties = {
Expand All @@ -35,6 +38,7 @@ export const nodeToNote = (node: Node): Note => {
mimeType: extraProperties.mimeType,
status: extraProperties.status,
summary: extraProperties.summary,
inkData: extraProperties.inkData ?? relayProperties.ink_data,
}

return {
Expand Down
1 change: 1 addition & 0 deletions webui/src/features/board/harness/convert/node-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const DIM0_TO_CANVAS: Record<Dim0NodeType, CanvasNodeType> = {
"code-sandbox": "code-sandbox",
widget: "widget",
"mini-app": "mini-app",
ink: "ink",
}


Expand Down
1 change: 1 addition & 0 deletions webui/src/features/board/harness/convert/note-to-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const AUTOFIT_DISABLED_TYPES = new Set([
"widget",
"mini-app",
"document",
"ink",
])


Expand Down
Loading
Loading