Skip to content
Open
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
11 changes: 3 additions & 8 deletions packages/core/src/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { themeOf, SIZES, FONT_SIZES, FONTS, GEO_IDS, COLOR_IDS, GRID_IDS, GRID_S
import {
localBounds, pageBounds, toLocal, drawShape, hitShape, marqueeHits,
scaleShape, textLayout, noteLayout, NOTE_W, sampleLinePts, resetTextLayouts,
sortShapesForRender,
} from './shapes.js'
import { boundsUnion, boundsExpand, boundsContain, clamp, rotWith } from './geometry.js'

Expand Down Expand Up @@ -463,14 +464,8 @@ export class Editor {
}

shapesSorted() {
// highlighter lives under the ink, like on paper — highlights render
// first (in their own z order), everything else above
return this.store.shapes().sort(
(a, b) =>
(a.type === 'highlight' ? 0 : 1) - (b.type === 'highlight' ? 0 : 1) ||
a.z - b.z ||
(a.id < b.id ? -1 : 1)
)
// order lives in shapes.js so out-of-tree exporters render identically
return sortShapesForRender(this.store.shapes())
}
hitTest(px, py) {
const tol = 8 / this.camera.z
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ export {
themeOf, THEMES, COLOR_IDS, SIZE_IDS, DASH_IDS, FILL_IDS, GEO_IDS, GRID_IDS,
SIZES, FONT_SIZES, FONTS,
} from './palette.js'
export { pageBounds, localBounds, drawShape, hitShape } from './shapes.js'
export { pageBounds, localBounds, drawShape, hitShape, textLayout, noteLayout } from './shapes.js'
export { boundsUnion, boundsExpand } from './geometry.js'
export { sortShapesForRender, setShapePlatform, resetShapePlatform } from './shapes.js'
export { strokeOutline, inkOptions } from './freehand.js'

import { Editor } from './editor.js'
Expand Down
85 changes: 67 additions & 18 deletions packages/core/src/shapes.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,42 @@ import {
} from './geometry.js'
import { strokeOutline, inkOptions } from './freehand.js'

// ---- rendering platform -----------------------------------------------------
// The engine draws with the browser's canvas stack by default (global Path2D,
// OffscreenCanvas/document canvases, Image, canvas text measurement).
// Non-browser renderers (e.g. a server-side exporter) inject native
// equivalents explicitly via setShapePlatform() — never by patching
// window/document/Path2D/Image, which this module does not touch. The
// browser never calls setShapePlatform, so its path is unchanged. Only
// stateless backend pieces live here (a Path2D class, a shared measure
// context, a tile-canvas factory); per-render state such as decoded images
// and hatch patterns travels on drawShape's opts.
const shapePlatform = { Path2D: null, measureCtx: null, tileCanvas: null }
export function setShapePlatform(p = {}) {
if (p.Path2D !== undefined) shapePlatform.Path2D = p.Path2D
if (p.measureCtx !== undefined) shapePlatform.measureCtx = p.measureCtx
if (p.tileCanvas !== undefined) shapePlatform.tileCanvas = p.tileCanvas
return shapePlatform
}
export function resetShapePlatform() {
shapePlatform.Path2D = null
shapePlatform.measureCtx = null
shapePlatform.tileCanvas = null
}
// Per-call opts win, then the injected platform, then the browser global.
const path2DFor = (opts) => opts?.Path2D ?? shapePlatform.Path2D ?? globalThis.Path2D

// Render order, shared by the editor and out-of-tree exporters so they cannot
// drift: highlighters soak into the paper beneath the ink, like on a real
// page — highlights render first (in their own z order), everything else
// above, ties broken by id.
export const sortShapesForRender = (shapes) => [...shapes].sort(
(a, b) =>
(a.type === 'highlight' ? 0 : 1) - (b.type === 'highlight' ? 0 : 1) ||
a.z - b.z ||
(a.id < b.id ? -1 : 1),
)

export const NOTE_W = 200
const NOTE_PAD = 20
const LABEL_PAD = 12
Expand Down Expand Up @@ -99,6 +135,7 @@ let layoutCache = new WeakMap() // text/note/geo-label props -> layout

let measureCtx = null
const measurer = () => {
if (shapePlatform.measureCtx) return shapePlatform.measureCtx
if (!measureCtx) measureCtx = (typeof OffscreenCanvas !== 'undefined'
? new OffscreenCanvas(1, 1)
: document.createElement('canvas')).getContext('2d')
Expand Down Expand Up @@ -243,12 +280,14 @@ export function geoOutline(shape) {
return { smooth: false, pts: bowPolygon(geoPolygon(p.geo, w, h), shape.id) }
}

// polygon path (hand-drawn for 'draw' dash), cached per props object
function geoPath(shape) {
// polygon path (hand-drawn for 'draw' dash), cached per props object.
// opts (drawShape's) may carry an injected Path2D for non-browser backends.
function geoPath(shape, opts) {
const p = shape.props
let path = geoPathCache.get(p)
if (path) return path
path = new Path2D()
const P2D = path2DFor(opts)
path = new P2D()
if (p.dash === 'draw') {
const { smooth, pts } = geoOutline(shape)
if (smooth) traceSmooth(path, pts, true)
Expand Down Expand Up @@ -277,13 +316,17 @@ function geoPath(shape) {
return path
}

const patternCache = new Map() // `${color}|${theme}` -> CanvasPattern
function hatchPattern(ctx, colorHex, themeId) {
const patternCache = new Map() // `${color}|${theme}` -> CanvasPattern (browser default)
function hatchPattern(ctx, colorHex, themeId, opts) {
const cache = opts?.patternCache ?? patternCache
const key = colorHex + '|' + themeId
let pat = patternCache.get(key)
let pat = cache.get(key)
if (pat) return pat
const c = document.createElement('canvas')
c.width = c.height = 8
const c = shapePlatform.tileCanvas
? shapePlatform.tileCanvas(8, 8)
: (typeof OffscreenCanvas !== 'undefined'
? new OffscreenCanvas(8, 8)
: (() => { const el = document.createElement('canvas'); el.width = el.height = 8; return el })())
const pctx = c.getContext('2d')
pctx.strokeStyle = colorHex
pctx.globalAlpha = 0.55
Expand All @@ -294,14 +337,14 @@ function hatchPattern(ctx, colorHex, themeId) {
pctx.moveTo(2, 10); pctx.lineTo(10, 2)
pctx.stroke()
pat = ctx.createPattern(c, 'repeat')
patternCache.set(key, pat)
cache.set(key, pat)
return pat
}

function fillPath(ctx, path, p, theme) {
function fillPath(ctx, path, p, theme, opts) {
if (!p.fill || p.fill === 'none') return
if (p.fill === 'semi') ctx.fillStyle = SEMI[theme.id]
else if (p.fill === 'pattern') ctx.fillStyle = hatchPattern(ctx, theme.colors[p.color].stroke, theme.id)
else if (p.fill === 'pattern') ctx.fillStyle = hatchPattern(ctx, theme.colors[p.color].stroke, theme.id, opts)
else ctx.fillStyle = theme.colors[p.color].fill
ctx.fill(path)
}
Expand All @@ -321,13 +364,13 @@ function drawLabel(ctx, layout, color, w, h) {

// the freehand outline as a Path2D, cached; live strokes (done !== true)
// rebuild every frame so the ink grows under the pen
function drawPath(shape) {
function drawPath(shape, opts) {
const p = shape.props
if (p.done) {
const hit = outlineCache.get(p)
if (hit) return hit
}
const path = new Path2D()
const path = new (path2DFor(opts))()
const outline = strokeOutline(p.pts, inkOptions(INK_SIZES[p.size], { isPen: !!p.isPen, done: !!p.done }))
traceSmooth(path, outline, true)
path.closePath()
Expand All @@ -343,7 +386,9 @@ function inkReach(size) {

// Draw one shape. ctx is already in PAGE space (camera applied by caller);
// this applies the shape's own translate/rotate.
// opts: { theme, store, zoom, onAssetLoad, ghost }
// opts: { theme, store, zoom, onAssetLoad, ghost, hideText,
// Path2D (injected path class), images (Map assetId -> decoded image,
// when present the browser loader is bypassed), patternCache (Map) }
export function drawShape(ctx, shape, opts) {
const { theme } = opts
const p = shape.props
Expand Down Expand Up @@ -377,7 +422,7 @@ export function drawShape(ctx, shape, opts) {
ctx.setLineDash([])
} else {
ctx.fillStyle = col.stroke
ctx.fill(drawPath(shape))
ctx.fill(drawPath(shape, opts))
}
break
}
Expand All @@ -398,8 +443,8 @@ export function drawShape(ctx, shape, opts) {
break
}
case 'geo': {
const path = geoPath(shape)
fillPath(ctx, path, p, theme)
const path = geoPath(shape, opts)
fillPath(ctx, path, p, theme, opts)
ctx.strokeStyle = col.stroke
strokeStyled(ctx, p.dash, SIZES[p.size])
ctx.stroke(path)
Expand Down Expand Up @@ -484,7 +529,11 @@ export function drawShape(ctx, shape, opts) {
break
}
case 'image': {
const img = assetImage(opts.store, p.assetId, opts.onAssetLoad)
// Non-browser renderers pass opts.images (pre-decoded per render) and
// never touch the browser loader; the browser passes no images map.
const img = opts.images
? (opts.images.get(p.assetId) ?? null)
: assetImage(opts.store, p.assetId, opts.onAssetLoad)
if (img) {
ctx.beginPath()
ctx.roundRect(0, 0, p.w, p.h, 4)
Expand Down
109 changes: 109 additions & 0 deletions packages/core/test/platform.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
// The rendering seam: explicit backend injection for non-browser renderers.
// These tests pass fakes through opts / setShapePlatform and assert the
// fakes (not browser globals) are what the engine uses.
import { describe, it, expect, afterEach } from 'vitest'
import {
drawShape, textLayout, resetTextLayouts,
setShapePlatform, resetShapePlatform, sortShapesForRender,
} from '../src/shapes.js'
import { themeOf } from '../src/palette.js'
import { Store } from '../src/store.js'

const theme = themeOf('light')
const store = new Store()

afterEach(() => {
resetShapePlatform()
resetTextLayouts()
})

// minimal 2d context: records calls, returns canned values
const stubCtx = () => {
const calls = []
const ctx = new Proxy({
canvas: {},
measureText: () => ({ width: 10, fontBoundingBoxAscent: 8, fontBoundingBoxDescent: 2 }),
createPattern: () => ({ token: 'pattern' }),
get fillStyle() { return this._fillStyle },
set fillStyle(v) { this._fillStyle = v },
}, {
get(t, k) {
if (k in t) return t[k]
return (...a) => { calls.push([k, a]); return undefined }
},
set(t, k, v) { t[k] = v; return true },
})
return { ctx, calls }
}

class FakePath {
constructor() { FakePath.constructed++ }
moveTo() {}
lineTo() {}
quadraticCurveTo() {}
bezierCurveTo() {}
ellipse() {}
closePath() {}
}
FakePath.constructed = 0

describe('platform injection', () => {
it('drawShape builds geo paths with the injected Path2D class', () => {
FakePath.constructed = 0
const { ctx } = stubCtx()
drawShape(ctx, {
id: 'g', typeName: 'shape', type: 'geo', x: 0, y: 0, rot: 0, z: 1,
props: { geo: 'rectangle', w: 10, h: 10, color: 'black', size: 'm', dash: 'solid', fill: 'none' },
}, { theme, store, Path2D: FakePath, patternCache: new Map() })
expect(FakePath.constructed).toBeGreaterThan(0)
})

it('pattern fills use the per-render cache, not the shared one', () => {
const { ctx } = stubCtx()
const mine = new Map()
const shape = {
id: 'g', typeName: 'shape', type: 'geo', x: 0, y: 0, rot: 0, z: 1,
props: { geo: 'rectangle', w: 10, h: 10, color: 'black', size: 'm', dash: 'solid', fill: 'pattern' },
}
drawShape(ctx, shape, { theme, store, Path2D: FakePath, patternCache: mine })
expect(mine.size).toBe(1)
expect(ctx.fillStyle).toEqual({ token: 'pattern' })
})

it('image shapes draw from opts.images without touching the browser loader', () => {
const seen = []
const ctx = stubCtx().ctx
ctx.drawImage = (...a) => seen.push(a)
ctx.clip = () => {}
ctx.roundRect = () => {}
const decoded = { fake: 'bitmap' }
drawShape(ctx, {
id: 'i', typeName: 'shape', type: 'image', x: 0, y: 0, rot: 0, z: 1,
props: { w: 40, h: 30, assetId: 'asset:missing-from-store' },
}, { theme, store, images: new Map([['asset:missing-from-store', decoded]]) })
// asset is absent from the store, yet the pre-decoded bitmap draws:
// the loader (and its placeholder) is fully bypassed
expect(seen).toEqual([[decoded, 0, 0, 40, 30]])
})

it('text layout measures with the injected context', () => {
const measured = []
setShapePlatform({
measureCtx: { font: '', measureText: (t) => { measured.push(t); return { width: t.length * 7 } } },
})
const l = textLayout({
id: 't', typeName: 'shape', type: 'text', x: 0, y: 0, rot: 0, z: 1,
props: { text: 'hello', size: 'm', color: 'black', font: 'draw' },
})
expect(measured.length).toBeGreaterThan(0)
expect(l.w).toBeGreaterThan(0)
})

it('sortShapesForRender keeps highlighters beneath, z-ordered within groups', () => {
const s = (id, type, z) => ({ id, typeName: 'shape', type, x: 0, y: 0, rot: 0, z, props: {} })
expect(sortShapesForRender([
s('geo-hi', 'geo', 99), s('hl-lo', 'highlight', 1), s('img', 'image', 50),
]).map((x) => x.id)).toEqual(['hl-lo', 'img', 'geo-hi'])
})
})
17 changes: 16 additions & 1 deletion packages/core/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,23 @@ export function pageBounds(shape: ShapeRecord): Bounds
export function drawShape(
ctx: CanvasRenderingContext2D,
shape: ShapeRecord,
opts: { theme: Theme; store: Store; zoom?: number; ghost?: boolean; onAssetLoad?: () => void }
opts: {
theme: Theme; store: Store; zoom?: number; ghost?: boolean; onAssetLoad?: () => void;
hideText?: string | null;
Path2D?: new () => Path2D;
images?: Map<string, CanvasImageSource | null>;
patternCache?: Map<string, CanvasPattern | null>;
}
): void
/** Render order: highlighters first (each group by z, ties by id). */
export function sortShapesForRender(shapes: ShapeRecord[]): ShapeRecord[]
/**
* Inject a non-browser rendering backend (Node/Skia). Never patches
* window/document globals; the browser never calls this.
*/
export function setShapePlatform(p: { Path2D?: any; measureCtx?: any; tileCanvas?: ((w: number, h: number) => any) | null }): unknown
/** Clear a backend previously installed with setShapePlatform. */
export function resetShapePlatform(): void
/** Point hit-test in page space. */
export function hitShape(shape: ShapeRecord, px: number, py: number, tol: number, store: Store): boolean

Expand Down