diff --git a/packages/core/src/editor.js b/packages/core/src/editor.js index 236e459..2997bc0 100644 --- a/packages/core/src/editor.js +++ b/packages/core/src/editor.js @@ -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' @@ -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 diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 79a44f7..2f41948 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -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' diff --git a/packages/core/src/shapes.js b/packages/core/src/shapes.js index 2fb5ed0..b4d9da8 100644 --- a/packages/core/src/shapes.js +++ b/packages/core/src/shapes.js @@ -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 @@ -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') @@ -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) @@ -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 @@ -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) } @@ -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() @@ -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 @@ -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 } @@ -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) @@ -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) diff --git a/packages/core/test/platform.test.js b/packages/core/test/platform.test.js new file mode 100644 index 0000000..f36a280 --- /dev/null +++ b/packages/core/test/platform.test.js @@ -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']) + }) +}) diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index f283c44..2e7d336 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -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; + patternCache?: Map; + } ): 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