web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: color picking for pen

+148 -32
+9 -1
apps/web/src/lib/canvas/canvas-store.svelte.ts
··· 123 123 const lineTool = new LineTool(); 124 124 const arrowTool = new ArrowTool(); 125 125 const textTool = new TextTool(); 126 - const penTool = new PenTool(() => brushStore.get()); 126 + const getPenBrushConfig = () => { 127 + const { color: _color, ...config } = brushStore.get(); 128 + return config; 129 + }; 130 + const getPenStrokeStyle = () => { 131 + const brush = brushStore.get(); 132 + return { color: brush.color, opacity: 1 }; 133 + }; 134 + const penTool = new PenTool(getPenBrushConfig, getPenStrokeStyle); 127 135 const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool, penTool]); 128 136 129 137 const textEditor = new TextEditorController(store, getViewport, refreshCursor);
+42 -8
apps/web/src/lib/components/BrushPopover.svelte
··· 1 1 <script lang="ts"> 2 - import type { BrushConfig } from 'inkfinite-core'; 2 + import type { BrushSettings } from '$lib/status'; 3 3 4 4 type Props = { 5 - brush: BrushConfig; 6 - onBrushChange: (brush: BrushConfig) => void; 5 + brush: BrushSettings; 6 + onBrushChange: (brush: BrushSettings) => void; 7 7 disabled?: boolean; 8 8 }; 9 9 ··· 18 18 let smoothing = $derived(brush.smoothing); 19 19 let streamline = $derived(brush.streamline); 20 20 let simulatePressure = $derived(brush.simulatePressure); 21 + let color = $derived(brush.color); 21 22 22 23 $effect(() => { 23 24 size = brush.size; ··· 25 26 smoothing = brush.smoothing; 26 27 streamline = brush.streamline; 27 28 simulatePressure = brush.simulatePressure; 29 + color = brush.color; 28 30 }); 29 31 30 32 $effect(() => { ··· 58 60 } 59 61 60 62 function handleSizeChange() { 61 - onBrushChange({ size, thinning, smoothing, streamline, simulatePressure }); 63 + onBrushChange({ size, thinning, smoothing, streamline, simulatePressure, color }); 62 64 } 63 65 64 66 function handleThinningInput(event: Event) { ··· 67 69 } 68 70 69 71 function handleThinningChange() { 70 - onBrushChange({ size, thinning, smoothing, streamline, simulatePressure }); 72 + onBrushChange({ size, thinning, smoothing, streamline, simulatePressure, color }); 71 73 } 72 74 73 75 function handleSmoothingInput(event: Event) { ··· 76 78 } 77 79 78 80 function handleSmoothingChange() { 79 - onBrushChange({ size, thinning, smoothing, streamline, simulatePressure }); 81 + onBrushChange({ size, thinning, smoothing, streamline, simulatePressure, color }); 80 82 } 81 83 82 84 function handleStreamlineInput(event: Event) { ··· 85 87 } 86 88 87 89 function handleStreamlineChange() { 88 - onBrushChange({ size, thinning, smoothing, streamline, simulatePressure }); 90 + onBrushChange({ size, thinning, smoothing, streamline, simulatePressure, color }); 89 91 } 90 92 91 93 function handleSimulatePressureChange(event: Event) { 92 94 const input = event.currentTarget as HTMLInputElement; 93 95 simulatePressure = input.checked; 94 - onBrushChange({ size, thinning, smoothing, streamline, simulatePressure }); 96 + onBrushChange({ size, thinning, smoothing, streamline, simulatePressure, color }); 97 + } 98 + 99 + function handleColorInput(event: Event) { 100 + const input = event.currentTarget as HTMLInputElement; 101 + color = input.value; 102 + } 103 + 104 + function handleColorChange() { 105 + onBrushChange({ size, thinning, smoothing, streamline, simulatePressure, color }); 95 106 } 96 107 </script> 97 108 ··· 179 190 oninput={handleStreamlineInput} 180 191 onchange={handleStreamlineChange} 181 192 aria-label="Brush streamline" /> 193 + </div> 194 + 195 + <div class="brush-popover__control brush-popover__control--color"> 196 + <label for="brush-color"> 197 + <span class="brush-popover__label">Color</span> 198 + <span class="brush-popover__value">{color}</span> 199 + </label> 200 + <input 201 + id="brush-color" 202 + type="color" 203 + value={color} 204 + oninput={handleColorInput} 205 + onchange={handleColorChange} 206 + aria-label="Brush color" /> 182 207 </div> 183 208 184 209 <div class="brush-popover__control brush-popover__control--checkbox"> ··· 308 333 .brush-popover__control--checkbox label { 309 334 flex-direction: row; 310 335 gap: 8px; 336 + } 337 + 338 + .brush-popover__control--color input[type='color'] { 339 + width: 100%; 340 + border: 1px solid var(--border); 341 + border-radius: 4px; 342 + height: 32px; 343 + background: var(--surface); 344 + cursor: pointer; 311 345 } 312 346 313 347 .brush-popover__control--checkbox input[type='checkbox'] {
+3 -4
apps/web/src/lib/components/Toolbar.svelte
··· 1 1 <script lang="ts"> 2 - import type { BrushStore } from '$lib/status'; 2 + import type { BrushSettings, BrushStore } from '$lib/status'; 3 3 import type { 4 4 ArrowShape, 5 5 Box2, 6 - BrushConfig, 7 6 EditorState as EditorStateType, 8 7 EllipseShape, 9 8 LineShape, ··· 61 60 let strokeColorValue = $state(DEFAULT_STROKE_COLOR); 62 61 let fillDisabled = $state(true); 63 62 let strokeDisabled = $state(true); 64 - let brush = $derived<BrushConfig>(brushStore.get()); 63 + let brush = $derived<BrushSettings>(brushStore.get()); 65 64 66 65 $effect(() => { 67 66 editorState = store.getState(); ··· 385 384 applyStrokeColor(input.value); 386 385 } 387 386 388 - function handleBrushChange(newBrush: BrushConfig) { 387 + function handleBrushChange(newBrush: BrushSettings) { 389 388 brushStore.set(newBrush); 390 389 } 391 390 </script>
+17 -8
apps/web/src/lib/status.ts
··· 24 24 set(next: SnapSettings): void; 25 25 }; 26 26 27 + export type BrushSettings = BrushConfig & { color: string }; 28 + 27 29 export type BrushStore = { 28 - get(): BrushConfig; 29 - subscribe(listener: (brush: BrushConfig) => void): () => void; 30 - update(updater: (brush: BrushConfig) => BrushConfig): void; 31 - set(next: BrushConfig): void; 30 + get(): BrushSettings; 31 + subscribe(listener: (brush: BrushSettings) => void): () => void; 32 + update(updater: (brush: BrushSettings) => BrushSettings): void; 33 + set(next: BrushSettings): void; 32 34 }; 33 35 34 36 export type PersistenceManager = { ··· 209 211 }; 210 212 } 211 213 212 - export function createBrushStore(initial?: Partial<BrushConfig>): BrushStore { 213 - const defaults: BrushConfig = { size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }; 214 - let value: BrushConfig = { ...defaults, ...initial }; 215 - const listeners = new Set<(brush: BrushConfig) => void>(); 214 + export function createBrushStore(initial?: Partial<BrushSettings>): BrushStore { 215 + const defaults: BrushSettings = { 216 + size: 16, 217 + thinning: 0.5, 218 + smoothing: 0.5, 219 + streamline: 0.5, 220 + simulatePressure: true, 221 + color: "#88c0d0", 222 + }; 223 + let value: BrushSettings = { ...defaults, ...initial }; 224 + const listeners = new Set<(brush: BrushSettings) => void>(); 216 225 217 226 return { 218 227 get() {
+20 -4
apps/web/src/lib/tests/BrushPopover.svelte.test.ts
··· 1 1 import BrushPopover from "$lib/components/BrushPopover.svelte"; 2 - import type { BrushConfig } from "inkfinite-core"; 2 + import type { BrushSettings } from "$lib/status"; 3 3 import { beforeEach, describe, expect, it, vi } from "vitest"; 4 4 import { render } from "vitest-browser-svelte"; 5 5 import { page } from "vitest/browser"; 6 6 7 7 describe("BrushPopover", () => { 8 - const defaultBrush: BrushConfig = { 8 + const defaultBrush: BrushSettings = { 9 9 size: 16, 10 10 thinning: 0.5, 11 11 smoothing: 0.5, 12 12 streamline: 0.5, 13 13 simulatePressure: true, 14 + color: "#88c0d0", 14 15 }; 15 16 16 17 beforeEach(async () => { ··· 79 80 }); 80 81 81 82 describe("Brush Controls", () => { 82 - it("displays all brush sliders when open", async () => { 83 + it("displays all brush controls when open", async () => { 83 84 const onBrushChange = vi.fn(); 84 85 render(BrushPopover, { brush: defaultBrush, onBrushChange }); 85 86 ··· 90 91 await expect.element(page.getByLabelText(/brush thinning/i)).toBeInTheDocument(); 91 92 await expect.element(page.getByLabelText(/brush smoothing/i)).toBeInTheDocument(); 92 93 await expect.element(page.getByLabelText(/brush streamline/i)).toBeInTheDocument(); 94 + await expect.element(page.getByLabelText(/brush color/i)).toBeInTheDocument(); 93 95 await expect.element(page.getByLabelText(/simulate pressure/i)).toBeInTheDocument(); 94 96 }); 95 97 96 98 it("displays current brush values", async () => { 97 - const customBrush: BrushConfig = { 99 + const customBrush: BrushSettings = { 98 100 size: 20, 99 101 thinning: 0.7, 100 102 smoothing: 0.3, 101 103 streamline: 0.9, 102 104 simulatePressure: false, 105 + color: "#ff6600", 103 106 }; 104 107 const onBrushChange = vi.fn(); 105 108 render(BrushPopover, { brush: customBrush, onBrushChange }); ··· 141 144 await checkbox.click(); 142 145 143 146 expect(onBrushChange).toHaveBeenCalledWith({ ...defaultBrush, simulatePressure: false }); 147 + }); 148 + 149 + it("calls onBrushChange when color input changes", async () => { 150 + const onBrushChange = vi.fn(); 151 + render(BrushPopover, { brush: defaultBrush, onBrushChange }); 152 + 153 + const button = page.getByRole("button", { name: /brush settings/i }); 154 + await button.click(); 155 + 156 + const colorInput = page.getByLabelText(/brush color/i); 157 + await colorInput.fill("#ff5577"); 158 + 159 + expect(onBrushChange).toHaveBeenCalledWith({ ...defaultBrush, color: "#ff5577" }); 144 160 }); 145 161 }); 146 162 });
+8 -1
apps/web/src/lib/tests/Canvas.history.test.ts
··· 60 60 set: () => {}, 61 61 }), 62 62 createBrushStore: () => ({ 63 - get: () => ({ size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }), 63 + get: () => ({ 64 + size: 16, 65 + thinning: 0.5, 66 + smoothing: 0.5, 67 + streamline: 0.5, 68 + simulatePressure: true, 69 + color: "#88c0d0", 70 + }), 64 71 subscribe: () => () => {}, 65 72 update: () => {}, 66 73 set: () => {},
+8 -1
apps/web/src/lib/tests/Canvas.keyboard.test.ts
··· 79 79 set: () => {}, 80 80 }), 81 81 createBrushStore: () => ({ 82 - get: () => ({ size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }), 82 + get: () => ({ 83 + size: 16, 84 + thinning: 0.5, 85 + smoothing: 0.5, 86 + streamline: 0.5, 87 + simulatePressure: true, 88 + color: "#88c0d0", 89 + }), 83 90 subscribe: () => () => {}, 84 91 update: () => {}, 85 92 set: () => {},
+8 -1
apps/web/src/lib/tests/Canvas.svelte.test.ts
··· 29 29 set: () => {}, 30 30 }), 31 31 createBrushStore: () => ({ 32 - get: () => ({ size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }), 32 + get: () => ({ 33 + size: 16, 34 + thinning: 0.5, 35 + smoothing: 0.5, 36 + streamline: 0.5, 37 + simulatePressure: true, 38 + color: "#88c0d0", 39 + }), 33 40 subscribe: () => () => {}, 34 41 update: () => {}, 35 42 set: () => {},
+8 -4
packages/core/src/tools/pen.ts
··· 1 1 import type { Action } from "../actions"; 2 - import type { BrushConfig, StrokePoint } from "../model"; 2 + import type { BrushConfig, StrokePoint, StrokeStyle } from "../model"; 3 3 import { createId, ShapeRecord } from "../model"; 4 4 import type { EditorState, ToolId } from "../reactivity"; 5 5 import { getCurrentPage } from "../reactivity"; ··· 44 44 /** 45 45 * Default stroke style 46 46 */ 47 - const DEFAULT_STYLE = { color: "#000000", opacity: 1.0 }; 47 + const DEFAULT_STYLE: StrokeStyle = { color: "#000000", opacity: 1.0 }; 48 48 49 49 /** 50 50 * Pen tool - creates freehand stroke shapes using perfect-freehand ··· 59 59 readonly id: ToolId = "pen"; 60 60 private toolState: PenToolState; 61 61 private getBrush: () => BrushConfig; 62 + private getStrokeStyle: () => StrokeStyle; 62 63 63 - constructor(getBrush?: () => BrushConfig) { 64 + constructor(getBrush?: () => BrushConfig, getStrokeStyle?: () => StrokeStyle) { 64 65 this.toolState = { 65 66 isDrawing: false, 66 67 draftPoints: [], ··· 69 70 lastUpdateFrame: null, 70 71 }; 71 72 this.getBrush = getBrush ?? (() => DEFAULT_BRUSH); 73 + this.getStrokeStyle = getStrokeStyle ?? (() => DEFAULT_STYLE); 72 74 } 73 75 74 76 onEnter(state: EditorState): EditorState { ··· 114 116 const shapeId = createId("shape"); 115 117 const firstPoint: StrokePoint = [action.world.x, action.world.y]; 116 118 119 + const strokeStyle = { ...this.getStrokeStyle() }; 120 + 117 121 const shape = ShapeRecord.createStroke(currentPage.id, 0, 0, { 118 122 points: [firstPoint], 119 123 brush: this.getBrush(), 120 - style: DEFAULT_STYLE, 124 + style: strokeStyle, 121 125 }, shapeId); 122 126 123 127 this.toolState.isDrawing = true;
+25
packages/core/tests/pen-tool.test.ts
··· 224 224 } 225 225 }); 226 226 227 + it("applies injected stroke style when creating strokes", () => { 228 + const tool = new PenTool(undefined, () => ({ color: "#88c0d0", opacity: 0.75 })); 229 + const store = new Store(); 230 + const page = PageRecord.create("Page 1", "page:1"); 231 + 232 + store.setState((state) => ({ 233 + ...state, 234 + doc: { ...state.doc, pages: { [page.id]: page } }, 235 + ui: { ...state.ui, currentPageId: page.id }, 236 + })); 237 + 238 + let state = store.getState(); 239 + 240 + state = tool.onAction(state, createPointerDownAction(0, 0)); 241 + state = tool.onAction(state, createPointerMoveAction(10, 0)); 242 + state = tool.onAction(state, createPointerUpAction(10, 0)); 243 + 244 + const shape = state.doc.shapes[Object.keys(state.doc.shapes)[0]]; 245 + expect(shape?.type).toBe("stroke"); 246 + if (shape?.type === "stroke") { 247 + expect(shape.props.style.color).toBe("#88c0d0"); 248 + expect(shape.props.style.opacity).toBeCloseTo(0.75); 249 + } 250 + }); 251 + 227 252 it("should not add point if moved less than minimum distance", () => { 228 253 const tool = new PenTool(); 229 254 const store = new Store();