web based infinite canvas

feat: Implement draggable toolbar and integrate theming into renderer

+271 -39
+3
TODO.txt
··· 145 - Flowchart: process, decision, terminator, data, document 146 - Diagrams: server, db, queue, user, browser, mobile 147 - UI: button, input, card, modal 148 149 (DoD): Stencils load as data and can spawn shapes deterministically. 150 ··· 215 without exporting. 216 - [ ] Markdown layout caching 217 - cache layout per (md, w, style) to avoid re-parsing on every render
··· 145 - Flowchart: process, decision, terminator, data, document 146 - Diagrams: server, db, queue, user, browser, mobile 147 - UI: button, input, card, modal 148 + - Etc: Post-It style/sticky notes, index cards, speech bubble 149 150 (DoD): Stencils load as data and can spawn shapes deterministically. 151 ··· 216 without exporting. 217 - [ ] Markdown layout caching 218 - cache layout per (md, w, style) to avoid re-parsing on every render 219 + - [ ] Bug Fix: Cursor Mapping 220 + - Investigate cursor position getting stuck or offsetting after window resize.
+110 -8
apps/web/src/app.css
··· 1 - @import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@300..700&display=swap'); 2 3 :root { 4 --bg-primary: #eceff4; ··· 105 --bg-primary: #161821; 106 --bg-secondary: #1e2132; 107 --bg-tertiary: #272c42; 108 - 109 --fg-primary: #c6c8d1; 110 --fg-secondary: #89b8c2; 111 --fg-tertiary: #84a0c6; 112 --fg-muted: #6b7089; 113 - 114 --accent-purple: #a093c7; 115 --accent-cyan: #89b8c2; 116 --accent-blue: #84a0c6; 117 --accent-search: #e4aa80; 118 - 119 --color-error: #e27878; 120 --color-warning: #e2a478; 121 --color-success: #b4be82; 122 --color-info: #e4aa80; 123 --color-purple: #a093c7; 124 - 125 --line-numbers: #444b71; 126 --selection: #272c42; 127 - 128 --surface: var(--bg-primary); 129 --surface-elevated: var(--bg-secondary); 130 --surface-overlay: var(--bg-tertiary); ··· 134 --border: var(--line-numbers); 135 --accent: var(--accent-blue); 136 --accent-hover: var(--accent-cyan); 137 } 138 139 * { ··· 143 } 144 145 body { 146 - font-family: 'Work Sans', sans-serif; 147 background-color: var(--surface); 148 color: var(--text); 149 line-height: 1.25; 150 } 151 152 button { 153 - font-family: 'Work Sans', sans-serif; 154 } 155 156 ::selection {
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300..700&display=swap'); 2 3 :root { 4 --bg-primary: #eceff4; ··· 105 --bg-primary: #161821; 106 --bg-secondary: #1e2132; 107 --bg-tertiary: #272c42; 108 --fg-primary: #c6c8d1; 109 --fg-secondary: #89b8c2; 110 --fg-tertiary: #84a0c6; 111 --fg-muted: #6b7089; 112 --accent-purple: #a093c7; 113 --accent-cyan: #89b8c2; 114 --accent-blue: #84a0c6; 115 --accent-search: #e4aa80; 116 --color-error: #e27878; 117 --color-warning: #e2a478; 118 --color-success: #b4be82; 119 --color-info: #e4aa80; 120 --color-purple: #a093c7; 121 --line-numbers: #444b71; 122 --selection: #272c42; 123 --surface: var(--bg-primary); 124 --surface-elevated: var(--bg-secondary); 125 --surface-overlay: var(--bg-tertiary); ··· 129 --border: var(--line-numbers); 130 --accent: var(--accent-blue); 131 --accent-hover: var(--accent-cyan); 132 + color-scheme: dark; 133 } 134 135 * { ··· 139 } 140 141 body { 142 + font-family: 'Inter', sans-serif; 143 + background-color: var(--surface); 144 + color: var(--text); 145 + line-height: 1.5; 146 + -webkit-font-smoothing: antialiased; 147 + -moz-osx-font-smoothing: grayscale; 148 + } 149 + 150 + button { 151 + font-family: 'Inter', sans-serif; 152 + } 153 + 154 + ::selection { 155 + background-color: var(--accent); 156 + color: var(--surface); 157 + } 158 + 159 + /* Markdown Styling */ 160 + .markdown-body { 161 + color: var(--text); 162 + font-size: 1rem; 163 + line-height: 1.6; 164 + } 165 + 166 + .markdown-body h1, 167 + .markdown-body h2, 168 + .markdown-body h3, 169 + .markdown-body h4, 170 + .markdown-body h5, 171 + .markdown-body h6 { 172 + margin-top: 1.5em; 173 + margin-bottom: 0.5em; 174 + font-weight: 600; 175 + line-height: 1.25; 176 + } 177 + 178 + .markdown-body h1 { font-size: 2em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; } 179 + .markdown-body h2 { font-size: 1.5em; border-bottom: 1px solid var(--border); padding-bottom: 0.3em; } 180 + .markdown-body h3 { font-size: 1.25em; } 181 + .markdown-body h4 { font-size: 1em; } 182 + 183 + .markdown-body p { 184 + margin-bottom: 1em; 185 + } 186 + 187 + .markdown-body ul, 188 + .markdown-body ol { 189 + padding-left: 2em; 190 + margin-bottom: 1em; 191 + } 192 + 193 + .markdown-body blockquote { 194 + padding: 0 1em; 195 + color: var(--text-muted); 196 + border-left: 0.25em solid var(--border); 197 + margin-bottom: 1em; 198 + } 199 + 200 + .markdown-body code { 201 + padding: 0.2em 0.4em; 202 + margin: 0; 203 + font-size: 85%; 204 + background-color: var(--bg-secondary); 205 + border-radius: 6px; 206 + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; 207 + } 208 + 209 + .markdown-body pre { 210 + padding: 16px; 211 + overflow: auto; 212 + font-size: 85%; 213 + line-height: 1.45; 214 + background-color: var(--bg-secondary); 215 + border-radius: 6px; 216 + margin-bottom: 1em; 217 + } 218 + 219 + .markdown-body pre code { 220 + background-color: transparent; 221 + padding: 0; 222 + } 223 + 224 + .markdown-body a { 225 + color: var(--accent); 226 + text-decoration: none; 227 + } 228 + 229 + .markdown-body a:hover { 230 + text-decoration: underline; 231 + } 232 + 233 + .markdown-body hr { 234 + height: 0.25em; 235 + padding: 0; 236 + margin: 24px 0; 237 + background-color: var(--border); 238 + border: 0; 239 + } 240 + 241 + * { 242 + margin: 0; 243 + padding: 0; 244 + box-sizing: border-box; 245 + } 246 + 247 + body { 248 + font-family: 'Inter', sans-serif; 249 background-color: var(--surface); 250 color: var(--text); 251 line-height: 1.25; 252 } 253 254 button { 255 + font-family: 'Inter', sans-serif; 256 } 257 258 ::selection {
+1
apps/web/src/lib/assets/grip-vertical.svg
···
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-more-vertical"><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>
+7
apps/web/src/lib/canvas/canvas-store.svelte.ts
··· 4 import { createPlatformRepo, detectPlatform } from "$lib/platform"; 5 import { createBrushStore, createPersistenceManager, createSnapStore, createStatusStore } from "$lib/status"; 6 import type { BrushStore, SnapStore, StatusStore } from "$lib/status"; 7 import { 8 ArrowTool, 9 Camera, ··· 526 get: () => ({ isPointerDown: pointerState.isPointerDown, snappedWorld: pointerState.snappedWorld }), 527 }, 528 handleProvider: { get: () => handleState.getSnapshot() }, 529 }); 530 531 const unsubStore = store.subscribe(() => renderer?.markDirty()); 532 const unsubSnap = snapStore.subscribe(() => renderer?.markDirty()); 533 534 inputAdapter = createInputAdapter({ 535 canvas,
··· 4 import { createPlatformRepo, detectPlatform } from "$lib/platform"; 5 import { createBrushStore, createPersistenceManager, createSnapStore, createStatusStore } from "$lib/status"; 6 import type { BrushStore, SnapStore, StatusStore } from "$lib/status"; 7 + import { themeStore } from "$lib/theme.svelte"; 8 import { 9 ArrowTool, 10 Camera, ··· 527 get: () => ({ isPointerDown: pointerState.isPointerDown, snappedWorld: pointerState.snappedWorld }), 528 }, 529 handleProvider: { get: () => handleState.getSnapshot() }, 530 + themeProvider: { get: () => themeStore.current }, 531 }); 532 533 const unsubStore = store.subscribe(() => renderer?.markDirty()); 534 const unsubSnap = snapStore.subscribe(() => renderer?.markDirty()); 535 + 536 + $effect(() => { 537 + themeStore.current; 538 + renderer?.markDirty(); 539 + }); 540 541 inputAdapter = createInputAdapter({ 542 canvas,
+3 -1
apps/web/src/lib/components/Icon.svelte
··· 1 <script lang="ts"> 2 import CloseIcon from '$lib/assets/close.svg?raw'; 3 import FolderIcon from '$lib/assets/folder.svg?raw'; 4 import InfoCircleIcon from '$lib/assets/info-circle.svg?raw'; 5 import MoonIcon from '$lib/assets/moon.svg?raw'; 6 import PencilIcon from '$lib/assets/pencil.svg?raw'; 7 import SunIcon from '$lib/assets/sun.svg?raw'; 8 import TrashIcon from '$lib/assets/trash.svg?raw'; 9 10 - export type IconName = 'close' | 'folder' | 'info-circle' | 'moon' | 'pencil' | 'sun' | 'trash'; 11 12 type Props = { name: IconName; size?: number; color?: string }; 13 ··· 16 const icons: Record<IconName, string> = { 17 close: CloseIcon, 18 folder: FolderIcon, 19 'info-circle': InfoCircleIcon, 20 moon: MoonIcon, 21 pencil: PencilIcon,
··· 1 <script lang="ts"> 2 import CloseIcon from '$lib/assets/close.svg?raw'; 3 import FolderIcon from '$lib/assets/folder.svg?raw'; 4 + import GripVerticalIcon from '$lib/assets/grip-vertical.svg?raw'; 5 import InfoCircleIcon from '$lib/assets/info-circle.svg?raw'; 6 import MoonIcon from '$lib/assets/moon.svg?raw'; 7 import PencilIcon from '$lib/assets/pencil.svg?raw'; 8 import SunIcon from '$lib/assets/sun.svg?raw'; 9 import TrashIcon from '$lib/assets/trash.svg?raw'; 10 11 + export type IconName = 'close' | 'folder' | 'grip-vertical' | 'info-circle' | 'moon' | 'pencil' | 'sun' | 'trash'; 12 13 type Props = { name: IconName; size?: number; color?: string }; 14 ··· 17 const icons: Record<IconName, string> = { 18 close: CloseIcon, 19 folder: FolderIcon, 20 + 'grip-vertical': GripVerticalIcon, 21 'info-circle': InfoCircleIcon, 22 moon: MoonIcon, 23 pencil: PencilIcon,
+128 -12
apps/web/src/lib/components/Toolbar.svelte
··· 33 shapeBounds, 34 SnapshotCommand 35 } from 'inkfinite-core'; 36 import icon from '../assets/favicon.svg'; 37 import ArrowPopover from './ArrowPopover.svelte'; 38 import BrushPopover from './BrushPopover.svelte'; ··· 129 } 130 }); 131 132 $effect(() => { 133 if (!zoomMenuOpen || typeof document === 'undefined') { 134 return; ··· 167 return () => document.removeEventListener('pointerdown', handlePointerDown); 168 }); 169 170 function openInfo() { 171 infoOpen = true; 172 } ··· 308 ); 309 } 310 311 function getSharedColor<T extends ShapeRecord>( 312 shapes: T[], 313 extract: (shape: T) => string | null | undefined ··· 436 } 437 </script> 438 439 - <div class="toolbar" role="toolbar" aria-label="Drawing tools"> 440 <div class="toolbar__brand"> 441 <div class="toolbar__logo"> 442 <img src={icon} alt="Inkfinite Icon" /> ··· 500 </button> 501 {/each} 502 503 - <div class="toolbar__colors" aria-label="Color controls"> 504 <label class="toolbar__color-control"> 505 <span>Fill</span> 506 <input 507 type="color" 508 value={fillColorValue} 509 onchange={handleFillChange} 510 - disabled={fillDisabled} 511 aria-label="Fill color" /> 512 </label> 513 <label class="toolbar__color-control"> 514 <span>Stroke</span> 515 <input 516 type="color" 517 value={strokeColorValue} 518 onchange={handleStrokeChange} 519 - disabled={strokeDisabled} 520 aria-label="Stroke color" /> 521 </label> 522 </div> 523 524 <div class="toolbar__divider"></div> 525 ··· 680 .toolbar { 681 display: flex; 682 gap: 0.75rem; 683 - padding: 0.75rem 1rem; 684 background: var(--surface-elevated); 685 border-bottom: 1px solid var(--border); 686 align-items: center; 687 box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 688 - z-index: 10; 689 - position: relative; 690 } 691 692 .toolbar__brand { 693 display: flex; ··· 720 align-items: center; 721 gap: 0.375rem; 722 padding: 0.625rem 0.875rem; 723 - border: 1px solid transparent; 724 border-radius: 0.5rem; 725 background: transparent; 726 - color: var(--text-muted); 727 cursor: pointer; 728 transition: all 0.2s ease; 729 min-width: 68px; 730 } 731 732 .toolbar__tool-button:hover { 733 background: var(--bg-tertiary); 734 color: var(--text); 735 } 736 737 .toolbar__tool-button:focus { ··· 743 .tool-button.active { 744 background: var(--accent); 745 color: var(--surface); 746 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 747 } 748 749 .toolbar__tool-icon { ··· 759 } 760 761 .toolbar__divider { 762 - width: 1px; 763 background-color: var(--border); 764 - margin: 0 1rem; 765 - height: 48px; 766 } 767 768 .toolbar__info {
··· 33 shapeBounds, 34 SnapshotCommand 35 } from 'inkfinite-core'; 36 + import { fade } from 'svelte/transition'; 37 import icon from '../assets/favicon.svg'; 38 import ArrowPopover from './ArrowPopover.svelte'; 39 import BrushPopover from './BrushPopover.svelte'; ··· 130 } 131 }); 132 133 + let showColorControls = $derived( 134 + toolSupportsStyles(currentTool) || 135 + toolSupportsFill(currentTool) || 136 + getSelectedShapes(editorState).some(s => shapeSupportsFill(s) || shapeSupportsStroke(s)) 137 + ); 138 + 139 + let position = $state({ x: 20, y: 20 }); 140 + let isDragging = $state(false); 141 + let dragOffset = $state({ x: 0, y: 0 }); 142 + let toolbarEl = $state<HTMLElement | null>(null); 143 + 144 $effect(() => { 145 if (!zoomMenuOpen || typeof document === 'undefined') { 146 return; ··· 179 return () => document.removeEventListener('pointerdown', handlePointerDown); 180 }); 181 182 + function handleDragStart(event: PointerEvent) { 183 + isDragging = true; 184 + dragOffset = { 185 + x: event.clientX - position.x, 186 + y: event.clientY - position.y 187 + }; 188 + 189 + if(typeof document !== 'undefined') document.body.style.userSelect = 'none'; 190 + 191 + 192 + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); 193 + } 194 + 195 + function handleDragMove(event: PointerEvent) { 196 + if (!isDragging) return; 197 + position = { 198 + x: event.clientX - dragOffset.x, 199 + y: event.clientY - dragOffset.y 200 + }; 201 + } 202 + 203 + function handleDragEnd(event: PointerEvent) { 204 + isDragging = false; 205 + if(typeof document !== 'undefined') document.body.style.userSelect = ''; 206 + (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId); 207 + } 208 + 209 function openInfo() { 210 infoOpen = true; 211 } ··· 347 ); 348 } 349 350 + function toolSupportsStyles(tool: ToolId): boolean { 351 + return ( 352 + tool === 'rect' || 353 + tool === 'ellipse' || 354 + tool === 'line' || 355 + tool === 'arrow' 356 + ); 357 + } 358 + 359 + function toolSupportsFill(tool: ToolId): boolean { 360 + return tool === 'rect' || tool === 'ellipse' || tool === 'text'; 361 + } 362 + 363 function getSharedColor<T extends ShapeRecord>( 364 shapes: T[], 365 extract: (shape: T) => string | null | undefined ··· 488 } 489 </script> 490 491 + <div 492 + class="toolbar" 493 + role="toolbar" 494 + aria-label="Drawing tools" 495 + bind:this={toolbarEl} 496 + style="position: fixed; left: {position.x}px; top: {position.y}px;" 497 + data-dragging={isDragging} 498 + > 499 + <!-- Drag Handle --> 500 + <div 501 + class="toolbar__drag-handle" 502 + onpointerdown={handleDragStart} 503 + onpointermove={handleDragMove} 504 + onpointerup={handleDragEnd} 505 + aria-label="Drag toolbar" 506 + role="button" 507 + tabindex="0" 508 + > 509 + <Icon name="grip-vertical" size={16} /> 510 + </div> 511 + 512 <div class="toolbar__brand"> 513 <div class="toolbar__logo"> 514 <img src={icon} alt="Inkfinite Icon" /> ··· 572 </button> 573 {/each} 574 575 + {#if showColorControls} 576 + <div class="toolbar__colors" aria-label="Color controls" transition:fade={{ duration: 150 }}> 577 + {#if toolSupportsFill(currentTool) || getSelectedShapes(editorState).some(shapeSupportsFill)} 578 <label class="toolbar__color-control"> 579 <span>Fill</span> 580 <input 581 type="color" 582 value={fillColorValue} 583 onchange={handleFillChange} 584 + disabled={fillDisabled && !toolSupportsFill(currentTool)} 585 aria-label="Fill color" /> 586 </label> 587 + {/if} 588 + {#if toolSupportsStyles(currentTool) || getSelectedShapes(editorState).some(shapeSupportsStroke)} 589 <label class="toolbar__color-control"> 590 <span>Stroke</span> 591 <input 592 type="color" 593 value={strokeColorValue} 594 onchange={handleStrokeChange} 595 + disabled={strokeDisabled && !toolSupportsStyles(currentTool)} 596 aria-label="Stroke color" /> 597 </label> 598 + {/if} 599 </div> 600 + {/if} 601 602 <div class="toolbar__divider"></div> 603 ··· 758 .toolbar { 759 display: flex; 760 gap: 0.75rem; 761 + padding: 0.75rem 1rem 0.75rem 0.25rem; /* Adjusted padding for handle */ 762 background: var(--surface-elevated); 763 border-bottom: 1px solid var(--border); 764 + border: 1px solid var(--border); 765 + border-radius: 0.75rem; 766 align-items: center; 767 box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 768 + z-index: 100; 769 + transition: transform 0.1s; 770 + touch-action: none; 771 } 772 + 773 + .toolbar[data-dragging="true"] { 774 + transform: scale(1.02); 775 + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); 776 + } 777 + 778 + .toolbar__drag-handle { 779 + display: flex; 780 + align-items: center; 781 + justify-content: center; 782 + width: 24px; 783 + height: 100%; 784 + cursor: grab; 785 + color: var(--text-muted); 786 + opacity: 0.5; 787 + transition: opacity 0.2s; 788 + touch-action: none; 789 + } 790 + 791 + .toolbar__drag-handle:hover { 792 + opacity: 1; 793 + color: var(--text); 794 + } 795 + 796 + .toolbar[data-dragging="true"] .toolbar__drag-handle { 797 + cursor: grabbing; 798 + opacity: 1; 799 + color: var(--accent); 800 + } 801 802 .toolbar__brand { 803 display: flex; ··· 830 align-items: center; 831 gap: 0.375rem; 832 padding: 0.625rem 0.875rem; 833 + border: 1px solid var(--border); 834 border-radius: 0.5rem; 835 background: transparent; 836 + color: var(--text); 837 cursor: pointer; 838 transition: all 0.2s ease; 839 min-width: 68px; 840 + opacity: 0.8; 841 } 842 843 .toolbar__tool-button:hover { 844 background: var(--bg-tertiary); 845 color: var(--text); 846 + opacity: 1; 847 + border-color: var(--text-muted); 848 } 849 850 .toolbar__tool-button:focus { ··· 856 .tool-button.active { 857 background: var(--accent); 858 color: var(--surface); 859 + border-color: var(--accent); 860 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 861 + opacity: 1; 862 } 863 864 .toolbar__tool-icon { ··· 874 } 875 876 .toolbar__divider { 877 + width: 2px; 878 background-color: var(--border); 879 + margin: 0 1.25rem; 880 + height: 32px; 881 + opacity: 0.5; 882 } 883 884 .toolbar__info {
+1 -9
packages/core/src/tools/markdown.ts
··· 4 import { getCurrentPage } from "../reactivity"; 5 import type { Tool } from "./base"; 6 7 - /** 8 - * Markdown tool creates markdown block shapes on click 9 - * 10 - * Features: 11 - * - Click to create a markdown block at the pointer position 12 - * - Block is created with default content and dimensions 13 - * - Shape is immediately selected after creation 14 - */ 15 export class MarkdownTool implements Tool { 16 readonly id: ToolId = "markdown"; 17 ··· 47 w: 300, 48 h: 200, 49 fontSize: 16, 50 - fontFamily: "sans-serif", 51 color: "#1f2933", 52 }, shapeId); 53
··· 4 import { getCurrentPage } from "../reactivity"; 5 import type { Tool } from "./base"; 6 7 export class MarkdownTool implements Tool { 8 readonly id: ToolId = "markdown"; 9 ··· 39 w: 300, 40 h: 200, 41 fontSize: 16, 42 + fontFamily: "Inter", 43 color: "#1f2933", 44 }, shapeId); 45
+18 -9
packages/renderer/src/index.ts
··· 47 cursorProvider?: { get(): CursorState }; 48 pointerStateProvider?: { get(): PointerVisualState }; 49 handleProvider?: { get(): HandleRenderState }; 50 }; 51 52 /** ··· 111 const cursorState = options?.cursorProvider?.get(); 112 const pointerState = options?.pointerStateProvider?.get(); 113 const handleState = options?.handleProvider?.get(); 114 - drawScene(context, state, viewport, snapSettings, cursorState, pointerState, handleState); 115 } 116 117 /** ··· 169 cursorState?: CursorState, 170 pointerState?: PointerVisualState, 171 handleState?: HandleRenderState, 172 ) { 173 context.clearRect(0, 0, viewport.width, viewport.height); 174 ··· 180 181 const shapes = getShapesOnCurrentPage(state); 182 for (const shape of shapes) { 183 - drawShape(context, state, shape); 184 } 185 186 drawSelection(context, state, shapes, handleState); ··· 349 /** 350 * Draw a single shape 351 */ 352 - function drawShape(context: CanvasRenderingContext2D, state: EditorState, shape: ShapeRecord) { 353 context.save(); 354 355 context.translate(shape.x, shape.y); ··· 379 break; 380 } 381 case "markdown": { 382 - drawMarkdown(context, shape); 383 break; 384 } 385 case "stroke": { ··· 627 * - Lists (ordered and unordered) 628 * - Code blocks (```) 629 */ 630 - function drawMarkdown(context: CanvasRenderingContext2D, shape: MarkdownShape) { 631 const { md, w, h, fontSize, fontFamily, color, bg, border } = shape.props; 632 633 const width = w; ··· 662 let prefix = ""; 663 664 if (line.startsWith("```")) { 665 - context.fillStyle = "#f4f4f4"; 666 const codeBlockLines = []; 667 lineIndex++; 668 while (lineIndex < lines.length && !lines[lineIndex].startsWith("```")) { ··· 674 if (yOffset + codeBlockHeight <= height - padding) { 675 context.fillRect(padding, yOffset, width - padding * 2, codeBlockHeight); 676 677 - context.fillStyle = "#333"; 678 context.font = `normal normal ${fontSize}px monospace`; 679 680 for (const [index, codeLine] of codeBlockLines.entries()) { ··· 728 const { text: segmentText, bold, italic, code } = segment; 729 730 if (code) { 731 - context.fillStyle = "#f4f4f4"; 732 const metrics = context.measureText(segmentText); 733 context.fillRect(xOffset, yOffset, metrics.width + 4, currentFontSize * 1.2); 734 - context.fillStyle = "#333"; 735 context.font = `normal normal ${currentFontSize * 0.9}px monospace`; 736 context.fillText(segmentText, xOffset + 2, yOffset); 737 xOffset += metrics.width + 4;
··· 47 cursorProvider?: { get(): CursorState }; 48 pointerStateProvider?: { get(): PointerVisualState }; 49 handleProvider?: { get(): HandleRenderState }; 50 + themeProvider?: { get(): "light" | "dark" }; 51 }; 52 53 /** ··· 112 const cursorState = options?.cursorProvider?.get(); 113 const pointerState = options?.pointerStateProvider?.get(); 114 const handleState = options?.handleProvider?.get(); 115 + const theme = options?.themeProvider?.get() ?? "light"; 116 + drawScene(context, state, viewport, snapSettings, cursorState, pointerState, handleState, theme); 117 } 118 119 /** ··· 171 cursorState?: CursorState, 172 pointerState?: PointerVisualState, 173 handleState?: HandleRenderState, 174 + theme: "light" | "dark" = "light", 175 ) { 176 context.clearRect(0, 0, viewport.width, viewport.height); 177 ··· 183 184 const shapes = getShapesOnCurrentPage(state); 185 for (const shape of shapes) { 186 + drawShape(context, state, shape, theme); 187 } 188 189 drawSelection(context, state, shapes, handleState); ··· 352 /** 353 * Draw a single shape 354 */ 355 + function drawShape( 356 + context: CanvasRenderingContext2D, 357 + state: EditorState, 358 + shape: ShapeRecord, 359 + theme: "light" | "dark" = "light", 360 + ) { 361 context.save(); 362 363 context.translate(shape.x, shape.y); ··· 387 break; 388 } 389 case "markdown": { 390 + drawMarkdown(context, shape, theme); 391 break; 392 } 393 case "stroke": { ··· 635 * - Lists (ordered and unordered) 636 * - Code blocks (```) 637 */ 638 + function drawMarkdown(context: CanvasRenderingContext2D, shape: MarkdownShape, theme: "light" | "dark" = "light") { 639 const { md, w, h, fontSize, fontFamily, color, bg, border } = shape.props; 640 641 const width = w; ··· 670 let prefix = ""; 671 672 if (line.startsWith("```")) { 673 + context.fillStyle = theme === "dark" ? "#2e3440" : "#f4f4f4"; 674 const codeBlockLines = []; 675 lineIndex++; 676 while (lineIndex < lines.length && !lines[lineIndex].startsWith("```")) { ··· 682 if (yOffset + codeBlockHeight <= height - padding) { 683 context.fillRect(padding, yOffset, width - padding * 2, codeBlockHeight); 684 685 + context.fillStyle = theme === "dark" ? "#e5e9f0" : "#333"; 686 context.font = `normal normal ${fontSize}px monospace`; 687 688 for (const [index, codeLine] of codeBlockLines.entries()) { ··· 736 const { text: segmentText, bold, italic, code } = segment; 737 738 if (code) { 739 + context.fillStyle = theme === "dark" ? "#2e3440" : "#f4f4f4"; 740 const metrics = context.measureText(segmentText); 741 context.fillRect(xOffset, yOffset, metrics.width + 4, currentFontSize * 1.2); 742 + 743 + context.fillStyle = theme === "dark" ? "#e5e9f0" : "#333"; 744 context.font = `normal normal ${currentFontSize * 0.9}px monospace`; 745 context.fillText(segmentText, xOffset + 2, yOffset); 746 xOffset += metrics.width + 4;