diff --git a/apps/scishop/.gitignore b/apps/scishop/.gitignore
new file mode 100644
index 0000000..0227833
--- /dev/null
+++ b/apps/scishop/.gitignore
@@ -0,0 +1,26 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+src/data/tmp*.json
\ No newline at end of file
diff --git a/apps/scishop/README.md b/apps/scishop/README.md
new file mode 100644
index 0000000..7f1ddfc
--- /dev/null
+++ b/apps/scishop/README.md
@@ -0,0 +1,3 @@
+# scishop
+
+Create, edit and export `Sci0` & `Sci01` PIC resources.
diff --git a/apps/scishop/index.html b/apps/scishop/index.html
new file mode 100644
index 0000000..3f20ae9
--- /dev/null
+++ b/apps/scishop/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ scishop
+
+
+
+
+
+
diff --git a/apps/scishop/package.json b/apps/scishop/package.json
new file mode 100644
index 0000000..aeac3e0
--- /dev/null
+++ b/apps/scishop/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "scishop",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@4bitlabs/sci0": "^4.0.1",
+ "@4bitlabs/color": "^2.1.5",
+ "@4bitlabs/color-space": "^1.2.3",
+ "@4bitlabs/image": "^3.3.11",
+ "@4bitlabs/vec2": "^1.2.0",
+ "fast-deep-equal": "^3.1.3",
+ "vue": "^3.4.21",
+ "transformation-matrix": "^2.16.1"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.4",
+ "typescript": "^5.2.2",
+ "vite": "^5.2.0",
+ "vue-tsc": "^2.0.6"
+ }
+}
diff --git a/apps/scishop/src/App.vue b/apps/scishop/src/App.vue
new file mode 100644
index 0000000..e098925
--- /dev/null
+++ b/apps/scishop/src/App.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/scishop/src/assets/cursor-pen-minus.svg b/apps/scishop/src/assets/cursor-pen-minus.svg
new file mode 100644
index 0000000..b858f1d
--- /dev/null
+++ b/apps/scishop/src/assets/cursor-pen-minus.svg
@@ -0,0 +1,8 @@
+
diff --git a/apps/scishop/src/assets/cursor-pen-plus.svg b/apps/scishop/src/assets/cursor-pen-plus.svg
new file mode 100644
index 0000000..ef82338
--- /dev/null
+++ b/apps/scishop/src/assets/cursor-pen-plus.svg
@@ -0,0 +1,8 @@
+
diff --git a/apps/scishop/src/assets/cursor-pen-star.svg b/apps/scishop/src/assets/cursor-pen-star.svg
new file mode 100644
index 0000000..8d34b71
--- /dev/null
+++ b/apps/scishop/src/assets/cursor-pen-star.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/apps/scishop/src/assets/cursor-pen.svg b/apps/scishop/src/assets/cursor-pen.svg
new file mode 100644
index 0000000..568bad7
--- /dev/null
+++ b/apps/scishop/src/assets/cursor-pen.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/apps/scishop/src/components/Header.vue b/apps/scishop/src/components/Header.vue
new file mode 100644
index 0000000..a74ef64
--- /dev/null
+++ b/apps/scishop/src/components/Header.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+ Pal
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/scishop/src/components/LayerNavigator.vue b/apps/scishop/src/components/LayerNavigator.vue
new file mode 100644
index 0000000..63887f3
--- /dev/null
+++ b/apps/scishop/src/components/LayerNavigator.vue
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
diff --git a/apps/scishop/src/components/PalettePicker/ModeSelector.vue b/apps/scishop/src/components/PalettePicker/ModeSelector.vue
new file mode 100644
index 0000000..619df9e
--- /dev/null
+++ b/apps/scishop/src/components/PalettePicker/ModeSelector.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
diff --git a/apps/scishop/src/components/PalettePicker/PalettePicker.vue b/apps/scishop/src/components/PalettePicker/PalettePicker.vue
new file mode 100644
index 0000000..bcbba2c
--- /dev/null
+++ b/apps/scishop/src/components/PalettePicker/PalettePicker.vue
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/scishop/src/components/PalettePicker/index.ts b/apps/scishop/src/components/PalettePicker/index.ts
new file mode 100644
index 0000000..469ca1e
--- /dev/null
+++ b/apps/scishop/src/components/PalettePicker/index.ts
@@ -0,0 +1,2 @@
+import PalettePicker from './PalettePicker.vue';
+export default PalettePicker;
diff --git a/apps/scishop/src/components/Sidebar.vue b/apps/scishop/src/components/Sidebar.vue
new file mode 100644
index 0000000..8203c01
--- /dev/null
+++ b/apps/scishop/src/components/Sidebar.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
diff --git a/apps/scishop/src/components/Stage.vue b/apps/scishop/src/components/Stage.vue
new file mode 100644
index 0000000..6a5e35f
--- /dev/null
+++ b/apps/scishop/src/components/Stage.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/scishop/src/components/StatusBar.vue b/apps/scishop/src/components/StatusBar.vue
new file mode 100644
index 0000000..6850fe5
--- /dev/null
+++ b/apps/scishop/src/components/StatusBar.vue
@@ -0,0 +1,39 @@
+
+
+
+
{{ formattedZoom }}×
+
+ {{ (rotateRef * (180 / Math.PI)).toFixed(0) }}°
+
+
+
+
diff --git a/apps/scishop/src/components/Toolbar.vue b/apps/scishop/src/components/Toolbar.vue
new file mode 100644
index 0000000..eabf1b1
--- /dev/null
+++ b/apps/scishop/src/components/Toolbar.vue
@@ -0,0 +1,134 @@
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+
diff --git a/apps/scishop/src/components/command-items/BrushCommandItem.vue b/apps/scishop/src/components/command-items/BrushCommandItem.vue
new file mode 100644
index 0000000..767692c
--- /dev/null
+++ b/apps/scishop/src/components/command-items/BrushCommandItem.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+ {{ isSpray ? 'Spray' : 'Solid' }}
+ Brush:
+ {{ isRect ? '\u{25A0}' : '\u{25CF}' }}
+ {{ size }}
+
+
+
+
diff --git a/apps/scishop/src/components/command-items/FillCommandItem.vue b/apps/scishop/src/components/command-items/FillCommandItem.vue
new file mode 100644
index 0000000..1bf5d51
--- /dev/null
+++ b/apps/scishop/src/components/command-items/FillCommandItem.vue
@@ -0,0 +1,19 @@
+
+
+
+
+ Fill
+
+
+
diff --git a/apps/scishop/src/components/command-items/GenericCommandItem.vue b/apps/scishop/src/components/command-items/GenericCommandItem.vue
new file mode 100644
index 0000000..d7ddc4f
--- /dev/null
+++ b/apps/scishop/src/components/command-items/GenericCommandItem.vue
@@ -0,0 +1,8 @@
+
+
+
+ {{ command[0] }}
+
diff --git a/apps/scishop/src/components/command-items/PolyLineCommandItem.vue b/apps/scishop/src/components/command-items/PolyLineCommandItem.vue
new file mode 100644
index 0000000..fb8818c
--- /dev/null
+++ b/apps/scishop/src/components/command-items/PolyLineCommandItem.vue
@@ -0,0 +1,19 @@
+
+
+
+
+ Line ({{ coords.length }})
+
+
+
diff --git a/apps/scishop/src/components/command-items/SetPaletteCommand.vue b/apps/scishop/src/components/command-items/SetPaletteCommand.vue
new file mode 100644
index 0000000..666f4de
--- /dev/null
+++ b/apps/scishop/src/components/command-items/SetPaletteCommand.vue
@@ -0,0 +1,13 @@
+
+
+
+ {{ command[0] }}: {{ pal }}
+
diff --git a/apps/scishop/src/components/command-items/Swatches.vue b/apps/scishop/src/components/command-items/Swatches.vue
new file mode 100644
index 0000000..f415fef
--- /dev/null
+++ b/apps/scishop/src/components/command-items/Swatches.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+ {{
+ drawCode[1].toString(16)
+ }}
+
+
+ {{
+ drawCode[2].toString(16)
+ }}
+
+
+
+
diff --git a/apps/scishop/src/composables/useCanvasRenderer.ts b/apps/scishop/src/composables/useCanvasRenderer.ts
new file mode 100644
index 0000000..a73c423
--- /dev/null
+++ b/apps/scishop/src/composables/useCanvasRenderer.ts
@@ -0,0 +1,75 @@
+import { computed, Ref, shallowRef, triggerRef, unref, watch } from 'vue';
+import { RenderResult } from '@4bitlabs/sci0/dist/screen/render-result.ts';
+
+import { DrawCommand, renderPic } from '@4bitlabs/sci0';
+import { createDitherFilter, renderPixelData } from '@4bitlabs/image';
+import { generateSciDitherPairs, Mixers } from '@4bitlabs/color';
+import { nearestNeighbor } from '@4bitlabs/resize-filters';
+import { isEqual, vec2, Vec2 } from '@4bitlabs/vec2';
+import { get2dContext } from '../helpers/getContext';
+import { setCanvasDimensions } from '../helpers/setCanvasDimensions.ts';
+import { clamp } from '../helpers/clamp.ts';
+import { mustInject } from '../data/mustInject.ts';
+import { paletteKey, stageOptionsKey, viewKey } from '../data/keys.ts';
+
+export function useRenderedPixels(picDataRef: Ref) {
+ const { canvasSize } = mustInject(stageOptionsKey);
+ return computed(() => {
+ const picData = unref(picDataRef);
+ const [width, height] = unref(canvasSize);
+ return renderPic(picData, { width, height });
+ });
+}
+
+export function useCanvasRenderer(
+ renderedRef: Ref,
+): Ref {
+ const { canvasSize } = mustInject(stageOptionsKey);
+ const { finalColors } = mustInject(paletteKey);
+ const { viewZoom: zoomRef } = mustInject(viewKey);
+
+ const canvasRef = shallowRef(new OffscreenCanvas(1, 1));
+
+ const oversampleRef = computed((prev = [1, 1]) => {
+ const zoom = unref(zoomRef);
+ if (zoom > 12) return [1, 1];
+ const samples = Math.round(clamp(zoom, 1, 5));
+ const next = vec2(samples, samples);
+ return isEqual(prev, next) ? prev : next;
+ });
+
+ const isSoftRef = computed(() => unref(zoomRef) >= 1);
+ const ditherRef = computed(() =>
+ createDitherFilter(
+ generateSciDitherPairs(
+ unref(finalColors),
+ Mixers.mixBy(0.25),
+ unref(isSoftRef) ? Mixers.softMixer() : ([a, b]) => [a, b],
+ ),
+ ),
+ );
+
+ watch(
+ [renderedRef, canvasSize, oversampleRef, ditherRef],
+ ([pic, [width, height], oversample, dither]) => {
+ const canvas = unref(canvasRef);
+ setCanvasDimensions(
+ canvas,
+ width * oversample[0],
+ height * oversample[1],
+ );
+
+ const imgData = renderPixelData(pic.visible, {
+ dither,
+ post: [nearestNeighbor(oversample)],
+ }) as ImageData;
+
+ const ctx = get2dContext(canvas);
+ ctx.putImageData(imgData, 0, 0);
+ triggerRef(canvasRef);
+ },
+ { immediate: true },
+ );
+
+ return canvasRef;
+}
diff --git a/apps/scishop/src/composables/useCursorEffect.ts b/apps/scishop/src/composables/useCursorEffect.ts
new file mode 100644
index 0000000..434e9dc
--- /dev/null
+++ b/apps/scishop/src/composables/useCursorEffect.ts
@@ -0,0 +1,51 @@
+import { Ref, ShallowRef, DeepReadonly, unref, computed } from 'vue';
+import { watch } from 'vue';
+import { applyToPoints, Matrix } from 'transformation-matrix';
+
+import { setCanvasDimensions } from '../helpers/setCanvasDimensions.ts';
+import { get2dContext } from '../helpers/getContext.ts';
+import { isInsideBounds, pixel } from '../helpers/polygons.ts';
+import { pixelBorder } from '../render/pixel-border.ts';
+import { cursorDot } from '../render/cursor-dot.ts';
+import { mustInject } from '../data/mustInject.ts';
+import { stageOptionsKey, toolKey } from '../data/keys.ts';
+import { CursorPosition } from './useCursorWatcher.ts';
+
+export function usePrecisionCursorEffect(
+ matrixRef: DeepReadonly[>,
+ elRef: ShallowRef,
+ stageResRef: DeepReadonly][>,
+ cursorPos: CursorPosition,
+) {
+ const toolRef = mustInject(toolKey);
+ const { canvasSize } = mustInject(stageOptionsKey);
+
+ const isOverCanvas = computed(() => {
+ const [cWidth, cHeight] = unref(canvasSize);
+ return isInsideBounds([cWidth, cHeight], unref(cursorPos.pixel));
+ });
+
+ watch(
+ [elRef, stageResRef, matrixRef, cursorPos.screen, cursorPos.pixel],
+ ([el, [sWidth, sHeight], matrix, screenPosition, canvasPixel]) => {
+ if (!el) return;
+
+ setCanvasDimensions(el, sWidth, sHeight);
+ const ctx = get2dContext(el);
+ ctx.clearRect(0, 0, sWidth, sHeight);
+
+ const tool = unref(toolRef);
+ const simpleCursor = !(tool === 'line' || tool === 'fill');
+
+ if (simpleCursor) return;
+
+ // Draw precision cursor
+ if (isOverCanvas) {
+ ctx.save();
+ pixelBorder(ctx, applyToPoints(matrix, pixel(canvasPixel, -0.0125)));
+ cursorDot(ctx, screenPosition);
+ ctx.restore();
+ }
+ },
+ );
+}
diff --git a/apps/scishop/src/composables/useCursorWatcher.ts b/apps/scishop/src/composables/useCursorWatcher.ts
new file mode 100644
index 0000000..a0d2fd9
--- /dev/null
+++ b/apps/scishop/src/composables/useCursorWatcher.ts
@@ -0,0 +1,63 @@
+import { computed, ComputedRef, Ref, ShallowRef } from 'vue';
+import { onMounted, onUnmounted, unref } from 'vue';
+import { applyToPoint, inverse, Matrix } from 'transformation-matrix';
+
+import { isEqual, round, vec2 } from '@4bitlabs/vec2';
+import { useRafRef } from './useRafRef.ts';
+import { isInsideBounds } from '../helpers/polygons.ts';
+import { mustInject } from '../data/mustInject.ts';
+import { stageOptionsKey } from '../data/keys.ts';
+
+export interface CursorPosition {
+ screen: Readonly][>;
+ canvas: Readonly>;
+ pixel: Readonly>;
+ isOver: Readonly][>;
+}
+
+export function useCursorWatcher(
+ targetRef: ShallowRef,
+ matrixRef: Ref,
+): CursorPosition {
+ const { canvasSize } = mustInject(stageOptionsKey);
+
+ const iMatrixRef = computed(() => inverse(unref(matrixRef)));
+
+ const screenPositionRef = useRafRef<[number, number]>([0, 0]);
+ const canvasPositionRef = computed<[number, number]>((prev = vec2()) => {
+ const actual = applyToPoint(unref(iMatrixRef), unref(screenPositionRef));
+ const next = round(actual, vec2(), (i) => Math.floor(i * 8) / 8);
+ return isEqual(prev, next) ? prev : next;
+ });
+
+ const canvasPixelRef = computed<[number, number]>((prev = vec2()) => {
+ const next = round(unref(canvasPositionRef), vec2(), Math.floor);
+ return isEqual(prev, next) ? prev : next;
+ });
+
+ const isOverCanvasRef = computed(() => {
+ const canvasPoint = unref(canvasPixelRef);
+ const [cWidth, cHeight] = unref(canvasSize);
+ return isInsideBounds([cWidth, cHeight], canvasPoint);
+ });
+
+ onMounted(() => {
+ const target = unref(targetRef);
+ if (!target) return;
+ target.addEventListener('pointermove', (e) => {
+ screenPositionRef.value = [e.offsetX, e.offsetY];
+ });
+ });
+
+ onUnmounted(() => {
+ const target = unref(targetRef);
+ if (!target) return;
+ });
+
+ return {
+ screen: screenPositionRef,
+ canvas: canvasPositionRef,
+ pixel: canvasPixelRef,
+ isOver: isOverCanvasRef,
+ };
+}
diff --git a/apps/scishop/src/composables/useDebouncedRef.ts b/apps/scishop/src/composables/useDebouncedRef.ts
new file mode 100644
index 0000000..cadd737
--- /dev/null
+++ b/apps/scishop/src/composables/useDebouncedRef.ts
@@ -0,0 +1,18 @@
+import { customRef } from 'vue';
+
+export function useDebouncedRef(value: T, delay = 100) {
+ let timeout: ReturnType | null = null;
+ return customRef((track, trigger) => ({
+ get() {
+ track();
+ return value;
+ },
+ set(newValue) {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ value = newValue;
+ trigger();
+ }, delay);
+ },
+ }));
+}
diff --git a/apps/scishop/src/composables/useFindTool.ts b/apps/scishop/src/composables/useFindTool.ts
new file mode 100644
index 0000000..c08120b
--- /dev/null
+++ b/apps/scishop/src/composables/useFindTool.ts
@@ -0,0 +1,50 @@
+import { computed, onMounted, onUnmounted, Ref, unref } from 'vue';
+
+import { RenderResult } from '@4bitlabs/sci0';
+import { mustInject } from '../data/mustInject.ts';
+import { pointersKey, stageOptionsKey, toolKey } from '../data/keys.ts';
+import { CursorPosition } from './useCursorWatcher.ts';
+
+const MAX = ~0 >>> 0;
+
+export function useFindTool(
+ target: Ref,
+ pixels: Ref,
+ pos: CursorPosition,
+) {
+ const { selectedIdx, topIdx } = mustInject(pointersKey);
+ const toolRef = mustInject(toolKey);
+ const { canvasSize } = mustInject(stageOptionsKey);
+
+ const commandUnderPosition = computed(() => {
+ const isOver = unref(pos.isOver);
+ if (!isOver) return -1;
+
+ const [x, y] = unref(pos.pixel);
+ const [sWidth] = unref(canvasSize);
+ const { tBuffer } = unref(pixels);
+
+ const cmd = tBuffer[y * sWidth + x];
+ return cmd !== MAX ? cmd : -1;
+ });
+
+ const handleClick = (e: MouseEvent) => {
+ if (unref(toolRef) !== 'find') return;
+ const val = unref(commandUnderPosition);
+ if (val === -1) return;
+ selectedIdx.value = val;
+ if (e.shiftKey) topIdx.value = val + 1;
+ };
+
+ onMounted(() => {
+ const el = unref(target);
+ if (!el) return;
+ el.addEventListener('click', handleClick);
+ });
+
+ onUnmounted(() => {
+ const el = unref(target);
+ if (!el) return;
+ el.removeEventListener('click', handleClick);
+ });
+}
diff --git a/apps/scishop/src/composables/useInputMachine.ts b/apps/scishop/src/composables/useInputMachine.ts
new file mode 100644
index 0000000..a46b799
--- /dev/null
+++ b/apps/scishop/src/composables/useInputMachine.ts
@@ -0,0 +1,626 @@
+import type { ShallowRef } from 'vue';
+import {
+ computed,
+ onMounted,
+ shallowRef,
+ unref,
+ watch,
+ watchEffect,
+ onUnmounted,
+} from 'vue';
+import {
+ applyToPoint,
+ applyToPoints,
+ compose,
+ inverse,
+ Matrix,
+ rotateDEG,
+ scale,
+ translate,
+} from 'transformation-matrix';
+import deepEqual from 'fast-deep-equal';
+
+import { round, vec2, Vec2, sub, add } from '@4bitlabs/vec2';
+import { FillCommand, PolylineCommand } from '@4bitlabs/sci0';
+import { get2dContext } from '../helpers/getContext.ts';
+import { isInsidePolygon, pathPoly, rect } from '../helpers/polygons.ts';
+import { fillSkeleton } from '../render/fill-skeleton.ts';
+import { plineSkeleton } from '../render/pline-skeleton.ts';
+import {
+ anyPointCloseTo,
+ extractVertices,
+ FindResult,
+ moveFillVertex,
+ moveLineVertex,
+ mustGetVertexFrom,
+ nearestPointWithRange,
+ PointAlongPathResult,
+ pointAlongPaths,
+} from '../helpers/command-helpers.ts';
+import { insert, remove } from '../helpers/array-helpers.ts';
+import { BasicEditorCommand } from '../models/EditorCommand.ts';
+import cursorPenSvg from '../assets/cursor-pen.svg';
+import cursorPenStarSvg from '../assets/cursor-pen-star.svg';
+import cursorPenPlusSvg from '../assets/cursor-pen-plus.svg';
+import cursorPenMinusSvg from '../assets/cursor-pen-minus.svg';
+import { setCanvasDimensions } from '../helpers/setCanvasDimensions.ts';
+import { pointSkeleton } from '../render/point-skeleton.ts';
+import { useUpdateSelectionFn } from '../data/useUpdateSelectionFn.ts';
+import { useCurrentCommandActions } from '../data/useCurrentCommandActions.ts';
+import {
+ currentKey,
+ drawStateKey,
+ layersKey,
+ pointersKey,
+ toolKey,
+ viewKey,
+} from '../data/keys.ts';
+import { mustInject } from '../data/mustInject.ts';
+import { CursorPosition } from './useCursorWatcher.ts';
+
+const clampZoom = (current: number, next: number, min: number, max: number) => {
+ if (current * next < min) return min / current;
+ if (current * next > max) return max / current;
+ return next;
+};
+
+type SelectionEntry = [layerIdx: number, cmdIdx: number, vertexIdx: number];
+
+type EmptyDragState = ['none'];
+type ViewDragState = ['view', iMatrix: Matrix, iPosition: Vec2];
+type PointDragEntry = [...SelectionEntry, initial: Vec2];
+type PointDragState = ['point', iPosition: Vec2, ...PointDragEntry[]];
+type SelectionDragState = ['sel-rect', start: Vec2];
+
+type DragStates =
+ | EmptyDragState
+ | ViewDragState
+ | PointDragState
+ | SelectionDragState;
+
+export function useInputMachine(
+ matrixRef: ShallowRef,
+ canvasRef: ShallowRef,
+ selCanvasRef: ShallowRef,
+ stageResRef: ShallowRef<[number, number]>,
+ cursorPosition: CursorPosition,
+) {
+ const layersRef = mustInject(layersKey);
+ const toolRef = mustInject(toolKey);
+ const currentRef = mustInject(currentKey);
+ const { matrix: viewMatrixRef, viewZoom: zoomRef } = mustInject(viewKey);
+ const { raw: rawDrawState } = mustInject(drawStateKey);
+ const { topIdx: topIdxRef, selectedIdx: selectedIdxRef } =
+ mustInject(pointersKey);
+
+ const updateSelection = useUpdateSelectionFn({
+ layers: layersRef,
+ topIdx: topIdxRef,
+ selectedIdx: selectedIdxRef,
+ });
+
+ const cmdStore = useCurrentCommandActions({
+ layers: layersRef,
+ current: currentRef,
+ topIdx: topIdxRef,
+ selectedIdx: selectedIdxRef,
+ });
+
+ const selectedLayerRef = computed(() => {
+ const selIdx = unref(selectedIdxRef);
+ return selIdx !== null ? unref(layersRef)[selIdx] : null;
+ });
+
+ const activeLayerRef = computed(() => {
+ const current = unref(currentRef);
+ if (current) return current;
+ return unref(selectedLayerRef);
+ });
+
+ const pointerRadiusRef = computed(() =>
+ Math.max(0.404, 7.5 / unref(zoomRef)),
+ );
+
+ const iMatrixRef = computed(() => inverse(unref(matrixRef)));
+ const dragStateRef = shallowRef(['none']);
+ const selectionStateRef = shallowRef([]);
+
+ const {
+ screen: lastCursorPositionRef,
+ canvas: canvasPositionRef,
+ pixel: canvasPixelRef,
+ } = cursorPosition;
+
+ const nearestExistingPointRef = computed((prev = null) => {
+ const cmds = unref(selectedLayerRef)?.commands;
+ if (!cmds || cmds.length < 1) return null;
+
+ const cPos = unref(canvasPositionRef);
+ const radius = unref(pointerRadiusRef);
+ const next = nearestPointWithRange(cmds, cPos, radius);
+
+ return deepEqual(prev, next) ? prev : next;
+ });
+
+ const nearestAddPointRef = computed(
+ (prev = null) => {
+ const cmds = unref(selectedLayerRef)?.commands;
+ if (!cmds || cmds.length < 1) return null;
+
+ const cPos = unref(canvasPositionRef);
+ const radius = unref(pointerRadiusRef);
+ const next = pointAlongPaths(cmds, cPos, radius);
+
+ return deepEqual(prev, next) ? prev : next;
+ },
+ );
+
+ // Cursor updater
+ watchEffect(() => {
+ const el = unref(canvasRef);
+ if (!el) return;
+
+ const [dragMode] = unref(dragStateRef);
+ const selectedTool = unref(toolRef);
+
+ let currentCursor = 'auto';
+ if (dragMode === 'view') {
+ currentCursor = 'grabbing';
+ } else if (selectedTool === 'find') {
+ currentCursor = 'crosshair';
+ } else if (selectedTool === 'pan') {
+ currentCursor = 'grab';
+ } else if (selectedTool === 'select') {
+ currentCursor = 'crosshair';
+ } else if (selectedTool == 'line') {
+ const isOverCanvas = unref(cursorPosition.isOver);
+ if (isOverCanvas) {
+ if (unref(currentRef)) {
+ currentCursor = `url(${cursorPenSvg}) 0 0, none`;
+ } else if (unref(nearestExistingPointRef) !== null) {
+ currentCursor = `url(${cursorPenMinusSvg}) 0 0, none`;
+ } else if (unref(nearestAddPointRef) !== null) {
+ currentCursor = `url(${cursorPenPlusSvg}) 0 0, none`;
+ } else {
+ currentCursor = `url(${cursorPenStarSvg}) 0 0, none`;
+ }
+ }
+ }
+ el.style.cursor = currentCursor;
+ });
+
+ // Apply current pan state
+ watch([dragStateRef, lastCursorPositionRef], ([dragState, [cX, cY]]) => {
+ const [mode] = dragState;
+ if (mode !== 'view') return;
+
+ const [, matrix, [ix, iy]] = dragState;
+ const dx = ix - cX;
+ const dy = iy - cY;
+ viewMatrixRef.value = compose(translate(-dx, -dy), matrix);
+ });
+
+ // Apply point drag state
+ watch([canvasPositionRef, dragStateRef], ([currentPosition, dragState]) => {
+ if (!dragState) return;
+ const [mode] = dragState;
+ if (mode !== 'point') return;
+
+ const [, initialPosition, ...pairs] = dragState;
+ const delta = sub(currentPosition, initialPosition);
+
+ layersRef.value = pairs.reduce((prevLayers, [lIdx, cIdx, vIdx, iVec]) => {
+ const nextVec = round(add(iVec, delta));
+ const layer = prevLayers[lIdx];
+
+ switch (layer?.type) {
+ case 'PLINE': {
+ const cmd = layer.commands[cIdx];
+ const next: BasicEditorCommand = {
+ ...layer,
+ commands: [moveLineVertex(cmd, vIdx, nextVec)],
+ };
+ return insert(prevLayers, lIdx, next, true);
+ }
+ case 'FILL': {
+ const cmd = layer.commands[cIdx];
+ const next: BasicEditorCommand = {
+ ...layer,
+ commands: [moveFillVertex(cmd, nextVec)],
+ };
+ return insert(prevLayers, lIdx, next, true);
+ }
+ }
+ return prevLayers;
+ }, unref(layersRef));
+ });
+
+ // Update Selection UI
+ watch(
+ [selCanvasRef, stageResRef, dragStateRef, lastCursorPositionRef],
+ ([el, [sWidth, sHeight], dragState, p1]) => {
+ if (!el) return;
+ setCanvasDimensions(el, sWidth, sHeight);
+
+ const [dragMode] = dragState;
+ if (dragMode !== 'sel-rect') return;
+
+ const ctx = get2dContext(el);
+ ctx.clearRect(0, 0, sWidth, sHeight);
+
+ const [, p0] = dragState;
+ ctx.save();
+ const selRect = rect(p0, p1);
+ pathPoly(ctx, selRect);
+ ctx.fillStyle = 'rgba(42 82 190 / 25%)';
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(42 82 190 / 100%)';
+ ctx.stroke();
+ ctx.restore();
+ },
+ );
+
+ watch([selCanvasRef, dragStateRef], ([el, [dragMode]]) => {
+ if (!el) return;
+ if (dragMode !== 'none') return;
+ setCanvasDimensions(el, 1, 1);
+ const ctx = get2dContext(el);
+ ctx.clearRect(0, 0, 1, 1);
+ });
+
+ // Update the UI canvas
+ watch(
+ [canvasRef, stageResRef, matrixRef, activeLayerRef, selectionStateRef],
+ ([el, [sWidth, sHeight], matrix, layer, selection]) => {
+ if (!el) return;
+ setCanvasDimensions(el, sWidth, sHeight);
+ const ctx = get2dContext(el);
+ ctx.clearRect(0, 0, sWidth, sHeight);
+
+ if (layer) {
+ ctx.save();
+ layer.commands.forEach((cmd) => {
+ const [type] = cmd;
+ ctx.strokeStyle = 'white';
+ ctx.fillStyle = '#ddd';
+ if (type === 'PLINE') plineSkeleton(ctx, matrix, cmd);
+ if (type === 'FILL') fillSkeleton(ctx, matrix, cmd);
+ });
+ ctx.restore();
+ }
+
+ ctx.save();
+ ctx.fillStyle = 'white';
+ selection.forEach(([lIdx, cIdx, vIdx]) => {
+ const layers = unref(layersRef);
+ const cmd = layers[lIdx]?.commands?.[cIdx];
+ if (!cmd) return;
+ const [type] = cmd;
+ if (type === 'PLINE' || type === 'FILL') {
+ const vertex = extractVertices(cmd)[vIdx];
+ if (!vertex) return;
+ pointSkeleton(ctx, matrix, vertex);
+ }
+ });
+ ctx.restore();
+ },
+ );
+
+ // update current
+ watch([canvasPixelRef], ([pos]) => {
+ const current = unref(currentRef);
+ if (current === null) return;
+ if (current.type === 'PLINE') {
+ const [type, options, ...coords] = current.commands[0];
+ cmdStore.begin({
+ ...current,
+ commands: [[type, options, ...coords.slice(0, -1), pos]],
+ });
+ }
+ });
+
+ watch([dragStateRef, lastCursorPositionRef], ([dragState, p1]) => {
+ const [mode] = dragState;
+
+ if (mode !== 'sel-rect') return;
+
+ // TODO rethink this
+ const layerIdx = unref(selectedIdxRef);
+ if (layerIdx === null) return;
+ const selectedLayer = unref(layersRef)[layerIdx];
+ if (!selectedLayer) return;
+ if (selectedLayer.type !== 'PLINE' && selectedLayer.type !== 'FILL') return;
+
+ const [, p0] = dragState;
+ const iMatrix = unref(iMatrixRef);
+ const bounds = applyToPoints(iMatrix, rect(p0, p1));
+ const selPoints: SelectionEntry[] = [];
+ selectedLayer.commands.forEach((cmd, cmdIdx) => {
+ const verts = extractVertices(cmd);
+ verts.forEach((v, vIdx) => {
+ if (!isInsidePolygon(bounds, v)) return;
+ selPoints.push([layerIdx, cmdIdx, vIdx]);
+ });
+ });
+ selectionStateRef.value = selPoints;
+ });
+
+ const mouseHandlers = {
+ contextMenu: (e: MouseEvent) => {
+ const selectedTool = unref(toolRef);
+ if (selectedTool === 'line') {
+ e.preventDefault();
+ const current = unref(currentRef);
+ if (current === null || current.type !== 'PLINE') return;
+
+ const [type, options, ...coords] = current.commands[0];
+ if (coords.length < 3) {
+ cmdStore.abort();
+ } else {
+ cmdStore.commit({
+ ...current,
+ commands: [[type, options, ...coords.slice(0, -1)]],
+ });
+ }
+ return;
+ }
+
+ if (selectedTool === 'select') {
+ e.preventDefault();
+ }
+ },
+
+ wheel: (e: WheelEvent) => {
+ const stage = unref(canvasRef);
+ if (!stage) return;
+ const [sWidth, sHeight] = unref(stageResRef);
+ const [dx, dy] = [e.offsetX - sWidth / 2, e.offsetY - sHeight / 2];
+ const scaleFactor = clampZoom(
+ unref(zoomRef),
+ e.deltaY / 1000 + 1,
+ 0.5,
+ 100,
+ );
+ viewMatrixRef.value = compose(
+ translate(dx, dy),
+ scale(scaleFactor),
+ rotateDEG(-e.deltaX / 40),
+ translate(-dx, -dy),
+ unref(viewMatrixRef),
+ );
+ },
+ };
+
+ const pointerHandlers = {
+ downPan(e: PointerEvent) {
+ const selectedTool = unref(toolRef);
+ const startPan =
+ (selectedTool === 'pan' && e.button === 0) || e.button === 1;
+
+ if (!startPan) return;
+
+ dragStateRef.value = [
+ 'view',
+ unref(viewMatrixRef),
+ [e.offsetX, e.offsetY],
+ ];
+
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ },
+
+ downSelectionMoveStart(e: PointerEvent) {
+ const selectedTool = unref(toolRef);
+ if (!(selectedTool === 'select' && e.button === 0)) return;
+
+ const layers = unref(layersRef);
+ const cPos = unref(canvasPositionRef);
+
+ const selState = unref(selectionStateRef);
+ const selectedVertices = selState.flatMap(([lIdx, cIdx, vIdx]) => {
+ const cmd = layers[lIdx]?.commands[cIdx];
+ if (!cmd) return [];
+ if (cmd[0] !== 'PLINE' && cmd[0] !== 'FILL') return [];
+ const vertex = extractVertices(cmd)[vIdx];
+ if (!vertex) return [];
+ const [x, y] = vertex;
+ return [vec2(x + 0.5, y + 0.5)];
+ });
+
+ const found = anyPointCloseTo(
+ selectedVertices,
+ cPos,
+ unref(pointerRadiusRef),
+ );
+
+ if (!found) return;
+
+ const dragEntries = selState.map(([lIdx, cIdx, pIdx]) => [
+ lIdx,
+ cIdx,
+ pIdx,
+ mustGetVertexFrom(layers[lIdx].commands, cIdx, pIdx),
+ ]);
+
+ dragStateRef.value = ['point', cPos, ...dragEntries];
+
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ },
+
+ downPointSelect(e: PointerEvent) {
+ const selectedTool = unref(toolRef);
+ if (!(selectedTool === 'select' && e.button === 0)) return;
+
+ const layers = unref(layersRef);
+ const cPos = unref(canvasPositionRef);
+
+ const iIdx = unref(selectedIdxRef);
+ if (iIdx === null) return;
+
+ const layer = layers[iIdx];
+ if (!layer) return;
+
+ const found = nearestPointWithRange(
+ layer.commands,
+ cPos,
+ unref(pointerRadiusRef),
+ );
+
+ if (found) {
+ const [cIdx, pIdx] = found;
+ const p = mustGetVertexFrom(layer.commands, cIdx, pIdx);
+ dragStateRef.value = ['point', cPos, [iIdx, cIdx, pIdx, p]];
+
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ return;
+ }
+ },
+
+ downPointStartRect(e: PointerEvent) {
+ const selectedTool = unref(toolRef);
+ if (!(selectedTool === 'select' && e.button === 0)) return;
+ selectionStateRef.value = [];
+ dragStateRef.value = ['sel-rect', vec2(e.offsetX, e.offsetY)];
+ },
+
+ downFill(e: MouseEvent) {
+ const selectedTool = unref(toolRef);
+ if (!(selectedTool === 'fill' && e.button === 0)) {
+ return;
+ }
+
+ const pos = round(
+ applyToPoint(unref(iMatrixRef), [e.offsetX, e.offsetY]),
+ vec2(),
+ Math.floor,
+ );
+
+ const [drawMode, ...drawCodes] = unref(rawDrawState);
+ cmdStore.commit({
+ id: Math.random().toString(36).substring(2),
+ type: 'FILL',
+ commands: [['FILL', [drawMode, drawCodes], pos]],
+ });
+
+ e.preventDefault();
+ },
+
+ downLine(e: MouseEvent) {
+ const selectedTool = unref(toolRef);
+ if (!(selectedTool === 'line' && e.button === 0)) {
+ return;
+ }
+
+ const pos = round(
+ applyToPoint(unref(iMatrixRef), [e.offsetX, e.offsetY]),
+ vec2(),
+ Math.floor,
+ );
+
+ const current = unref(currentRef);
+
+ // Append to current line
+ if (current?.type === 'PLINE') {
+ const [type, options, ...coords] = current.commands[0];
+ cmdStore.begin({
+ ...current,
+ commands: [
+ [type, options, ...coords.slice(0, -1), [...pos], [...pos]],
+ ],
+ });
+ e.preventDefault();
+ return;
+ }
+
+ // Remove an existing point on the selected line
+ const nearestExistingPoint = unref(nearestExistingPointRef);
+ if (nearestExistingPoint) {
+ const [cmdIdx, pointIdx] = nearestExistingPoint;
+ updateSelection((prev) => {
+ if (prev.type !== 'PLINE') return prev;
+
+ const [type, options, ...prevVerts] = prev.commands[cmdIdx];
+ const nextVerts = remove(prevVerts, pointIdx);
+
+ if (nextVerts.length <= 1) return null;
+ return {
+ ...prev,
+ commands: [[type, options, ...nextVerts]],
+ };
+ });
+ e.preventDefault();
+ return;
+ }
+
+ // Insert a new point on an existing line
+ const nearestAddPoint = unref(nearestAddPointRef);
+ if (nearestAddPoint) {
+ const [cmdIdx, , idx, vert] = nearestAddPoint;
+ updateSelection((prev) => {
+ if (prev.type !== 'PLINE') return prev;
+ const [type, options, ...prevVerts] = prev.commands[cmdIdx];
+ const nextVerts = insert(
+ prevVerts,
+ idx,
+ round(vert, vert, Math.floor),
+ );
+ return {
+ ...prev,
+ commands: [[type, options, ...nextVerts]],
+ };
+ });
+ e.preventDefault();
+ return;
+ }
+
+ // Start a new line
+ if (current === null) {
+ const [drawMode, ...drawCodes] = unref(rawDrawState);
+ cmdStore.begin({
+ id: Math.random().toString(36).substring(2),
+ type: 'PLINE',
+ commands: [['PLINE', [drawMode, drawCodes], pos, pos]],
+ });
+ e.preventDefault();
+ return;
+ }
+ },
+
+ up() {
+ dragStateRef.value = ['none'];
+ },
+ };
+
+ onMounted(() => {
+ const el = unref(canvasRef);
+ if (!el) return;
+ el.addEventListener('contextmenu', mouseHandlers.contextMenu);
+ el.addEventListener('wheel', mouseHandlers.wheel);
+ el.addEventListener('pointerdown', pointerHandlers.downPan);
+ el.addEventListener('pointerdown', pointerHandlers.downSelectionMoveStart);
+ el.addEventListener('pointerdown', pointerHandlers.downPointSelect);
+ el.addEventListener('pointerdown', pointerHandlers.downPointStartRect);
+ el.addEventListener('pointerdown', pointerHandlers.downFill);
+ el.addEventListener('pointerdown', pointerHandlers.downLine);
+ el.addEventListener('pointerup', pointerHandlers.up);
+ });
+
+ onUnmounted(() => {
+ const el = unref(canvasRef);
+ if (!el) return;
+ el.removeEventListener('pointerup', pointerHandlers.up);
+ el.removeEventListener('pointerdown', pointerHandlers.downLine);
+ el.removeEventListener('pointerdown', pointerHandlers.downFill);
+ el.removeEventListener('pointerdown', pointerHandlers.downPointStartRect);
+ el.removeEventListener('pointerdown', pointerHandlers.downPointSelect);
+ el.removeEventListener(
+ 'pointerdown',
+ pointerHandlers.downSelectionMoveStart,
+ );
+ el.removeEventListener('pointerdown', pointerHandlers.downPan);
+ el.removeEventListener('wheel', mouseHandlers.wheel);
+ el.removeEventListener('contextmenu', mouseHandlers.contextMenu);
+ });
+}
diff --git a/apps/scishop/src/composables/useRafRef.ts b/apps/scishop/src/composables/useRafRef.ts
new file mode 100644
index 0000000..ec645dd
--- /dev/null
+++ b/apps/scishop/src/composables/useRafRef.ts
@@ -0,0 +1,21 @@
+import { customRef } from 'vue';
+
+export function useRafRef(initialValue: T) {
+ let value = initialValue;
+ let rafHandle: number | null = null;
+
+ return customRef((track, trigger) => ({
+ get() {
+ track();
+ return value;
+ },
+ set(newValue) {
+ if (rafHandle) cancelAnimationFrame(rafHandle);
+ rafHandle = requestAnimationFrame(() => {
+ value = newValue;
+ trigger();
+ rafHandle = null;
+ });
+ },
+ }));
+}
diff --git a/apps/scishop/src/composables/useResizeWatcher.ts b/apps/scishop/src/composables/useResizeWatcher.ts
new file mode 100644
index 0000000..1afcdaa
--- /dev/null
+++ b/apps/scishop/src/composables/useResizeWatcher.ts
@@ -0,0 +1,37 @@
+import { watch, onUnmounted, triggerRef, ShallowRef } from 'vue';
+
+import { useRafRef } from './useRafRef.ts';
+
+export function useResizeWatcher(
+ refEl: ShallowRef,
+) {
+ const resolution = useRafRef<[width: number, height: number]>([-1, -1]);
+
+ const visiblityHandler = () => {
+ if (!document.hidden) {
+ triggerRef(resolution);
+ }
+ };
+
+ document.addEventListener('visibilitychange', visiblityHandler);
+
+ const watcher = new ResizeObserver((els) => {
+ const match = els.find((it) => it.target === refEl.value);
+ resolution.value = [
+ match?.contentRect.width ?? 0,
+ match?.contentRect.height ?? 0,
+ ];
+ });
+
+ watch(refEl, (next, prev) => {
+ if (prev) watcher.unobserve(prev);
+ if (next) watcher.observe(next);
+ });
+
+ onUnmounted(() => {
+ watcher.disconnect();
+ document.removeEventListener('visibilitychange', visiblityHandler);
+ });
+
+ return resolution;
+}
diff --git a/apps/scishop/src/data/initial-pic-data.ts b/apps/scishop/src/data/initial-pic-data.ts
new file mode 100644
index 0000000..c8ffc13
--- /dev/null
+++ b/apps/scishop/src/data/initial-pic-data.ts
@@ -0,0 +1,14 @@
+import { DrawCommand } from '@4bitlabs/sci0';
+import { EditorCommand } from '../models/EditorCommand.ts';
+import d from './tmp3.json';
+
+const nextId = () => Math.random().toString(36).substring(2);
+
+const wrapRawCommand = (cmd: DrawCommand): EditorCommand =>
+ ({ id: nextId(), type: cmd[0], commands: [cmd] }) as EditorCommand;
+
+const refData: DrawCommand[] = [...(d as unknown as DrawCommand[])];
+
+const data: EditorCommand[] = refData.map(wrapRawCommand);
+
+export default data;
diff --git a/apps/scishop/src/data/keys.ts b/apps/scishop/src/data/keys.ts
new file mode 100644
index 0000000..24ee3b0
--- /dev/null
+++ b/apps/scishop/src/data/keys.ts
@@ -0,0 +1,20 @@
+import { InjectionKey, Ref, ShallowRef } from 'vue';
+
+import type { EditorCommand } from '../models/EditorCommand.ts';
+import type { DrawStateStore } from './stores/draw-state-store.ts';
+import type { Tool } from '../models/tool.ts';
+import { LayerPointerStore } from './stores/layer-pointer-store.ts';
+import { ViewStore } from './stores/view-store.ts';
+import { PaletteStore } from './stores/palette-store.ts';
+import { StageOptionStore } from './stores/stage-option-store.ts';
+
+const keyOf = (name: string = '') => Symbol(name) as InjectionKey;
+
+export const layersKey = keyOf>('layers');
+export const pointersKey = keyOf('pointers');
+export const stageOptionsKey = keyOf('stageOptions');
+export const currentKey = keyOf>('current');
+export const viewKey = keyOf('viewKey');
+export const toolKey = keyOf][>('currentTool');
+export const drawStateKey = keyOf('drawState');
+export const paletteKey = keyOf('palette');
diff --git a/apps/scishop/src/data/mustInject.ts b/apps/scishop/src/data/mustInject.ts
new file mode 100644
index 0000000..c70b943
--- /dev/null
+++ b/apps/scishop/src/data/mustInject.ts
@@ -0,0 +1,12 @@
+import { InjectionKey } from '@vue/runtime-core';
+import { inject } from 'vue';
+
+export const mustInject = (
+ key: InjectionKey,
+ message = 'injection error',
+) => {
+ const value = inject(key);
+ if (value === undefined)
+ throw new Error(`${message}: ${String(key)} is not set`);
+ return value;
+};
diff --git a/apps/scishop/src/data/stores/draw-state-store.ts b/apps/scishop/src/data/stores/draw-state-store.ts
new file mode 100644
index 0000000..7094ee0
--- /dev/null
+++ b/apps/scishop/src/data/stores/draw-state-store.ts
@@ -0,0 +1,13 @@
+import { Ref, WritableComputedRef } from 'vue';
+
+import { DrawCodes, DrawMode } from '@4bitlabs/sci0';
+
+export interface DrawStateStore {
+ readonly raw: Ref<[DrawMode, ...DrawCodes]>;
+ readonly visualEnabled: WritableComputedRef;
+ readonly priorityEnabled: WritableComputedRef;
+ readonly controlEnabled: WritableComputedRef;
+ readonly visualCode: WritableComputedRef;
+ readonly priorityCode: WritableComputedRef;
+ readonly controlCode: WritableComputedRef;
+}
diff --git a/apps/scishop/src/data/stores/layer-pointer-store.ts b/apps/scishop/src/data/stores/layer-pointer-store.ts
new file mode 100644
index 0000000..275b4f2
--- /dev/null
+++ b/apps/scishop/src/data/stores/layer-pointer-store.ts
@@ -0,0 +1,6 @@
+import type { Ref } from 'vue';
+
+export interface LayerPointerStore {
+ topIdx: Ref;
+ selectedIdx: Ref;
+}
diff --git a/apps/scishop/src/data/stores/palette-store.ts b/apps/scishop/src/data/stores/palette-store.ts
new file mode 100644
index 0000000..5ff6840
--- /dev/null
+++ b/apps/scishop/src/data/stores/palette-store.ts
@@ -0,0 +1,14 @@
+import type { ComputedRef, Ref } from 'vue';
+
+import type { PaletteSet } from '../../helpers/palette-helpers.ts';
+
+export interface PaletteStore {
+ readonly contrast: Ref;
+ readonly baseColors: Ref;
+ readonly variant: Ref<0 | 1 | 2 | 3>;
+ readonly finalColors: ComputedRef;
+ readonly topPaletteSet: ComputedRef;
+ readonly currentPalette: ComputedRef;
+
+ resolvePaletteAtIdx(n: number): PaletteSet;
+}
diff --git a/apps/scishop/src/data/stores/stage-option-store.ts b/apps/scishop/src/data/stores/stage-option-store.ts
new file mode 100644
index 0000000..be00129
--- /dev/null
+++ b/apps/scishop/src/data/stores/stage-option-store.ts
@@ -0,0 +1,10 @@
+import type { ComputedRef, Ref } from 'vue';
+import type { Matrix } from 'transformation-matrix';
+
+import type { Vec2 } from '@4bitlabs/vec2';
+
+export interface StageOptionStore {
+ canvasSize: Ref;
+ aspectRatio: Ref;
+ aspectRatioScaleComponent: ComputedRef;
+}
diff --git a/apps/scishop/src/data/stores/view-store.ts b/apps/scishop/src/data/stores/view-store.ts
new file mode 100644
index 0000000..9af6e3d
--- /dev/null
+++ b/apps/scishop/src/data/stores/view-store.ts
@@ -0,0 +1,8 @@
+import type { ComputedRef, ShallowRef } from 'vue';
+import type { Matrix } from 'transformation-matrix';
+
+export interface ViewStore {
+ readonly matrix: ShallowRef;
+ readonly viewZoom: ComputedRef;
+ readonly viewRotation: ComputedRef;
+}
\ No newline at end of file
diff --git a/apps/scishop/src/data/useAppStoreProvider.ts b/apps/scishop/src/data/useAppStoreProvider.ts
new file mode 100644
index 0000000..2e47421
--- /dev/null
+++ b/apps/scishop/src/data/useAppStoreProvider.ts
@@ -0,0 +1,61 @@
+import { computed, provide, ref, shallowRef, unref } from 'vue';
+import { decomposeTSR, identity, scale } from 'transformation-matrix';
+
+import { vec2 } from '@4bitlabs/vec2';
+import * as Keys from './keys.ts';
+import data from './initial-pic-data.ts';
+import { Tool } from '../models/tool.ts';
+import { useDrawStateProvider } from './useDrawStateProvider.ts';
+import { usePaletteProvider } from './usePaletteProvider.ts';
+import { EditorCommand } from '../models/EditorCommand.ts';
+
+export function useAppStoreProvider() {
+ // Layers
+ const layersRef = shallowRef(data);
+ const topIdxRef = ref(data.length);
+ const selectedIdxRef = ref(data.length);
+
+ provide(Keys.layersKey, layersRef);
+ provide(Keys.pointersKey, {
+ topIdx: topIdxRef,
+ selectedIdx: selectedIdxRef,
+ });
+
+ // Current
+ const currentRef = shallowRef(null);
+ provide(Keys.currentKey, currentRef);
+
+ // Stage
+ const stageSize = ref(vec2(320, 190));
+ const aspectRatio = ref(vec2(5, 6));
+ const aspectRatioScaleComponent = computed(() => {
+ const [width, height] = unref(aspectRatio);
+ return scale(1, 1 / (width / height));
+ });
+
+ provide(Keys.stageOptionsKey, {
+ canvasSize: stageSize,
+ aspectRatio,
+ aspectRatioScaleComponent,
+ });
+
+ // Toolbar
+ const toolRef = ref('pan');
+
+ provide(Keys.toolKey, toolRef);
+
+ // View
+ const viewMatrixRef = shallowRef(identity());
+ const viewTransformsRef = computed(() => decomposeTSR(unref(viewMatrixRef)));
+ const viewZoom = computed(() => {
+ const { sx, sy } = unref(viewTransformsRef).scale;
+ return (sx + sy) / 2;
+ });
+ const viewRotation = computed(() => unref(viewTransformsRef).rotation.angle);
+ provide(Keys.viewKey, { matrix: viewMatrixRef, viewZoom, viewRotation });
+
+ // DrawState
+ useDrawStateProvider();
+ // Palette
+ usePaletteProvider({ layersRef, topIdxRef });
+}
diff --git a/apps/scishop/src/data/useCurrentCommandActions.ts b/apps/scishop/src/data/useCurrentCommandActions.ts
new file mode 100644
index 0000000..eff119f
--- /dev/null
+++ b/apps/scishop/src/data/useCurrentCommandActions.ts
@@ -0,0 +1,34 @@
+import { ShallowRef, Ref, unref } from 'vue';
+
+import { EditorCommand } from '../models/EditorCommand.ts';
+import { insert } from '../helpers/array-helpers.ts';
+
+export function useCurrentCommandActions(deps: {
+ layers: ShallowRef;
+ current: ShallowRef;
+ selectedIdx: Ref;
+ topIdx: Ref;
+}) {
+ const {
+ layers: layersRef,
+ current: currentRef,
+ selectedIdx: selectedIdxRef,
+ topIdx: topIdxRef,
+ } = deps;
+
+ return {
+ begin(cmd: EditorCommand) {
+ currentRef.value = cmd;
+ },
+ commit(cmd: EditorCommand) {
+ currentRef.value = null;
+ const insertPosition = unref(topIdxRef);
+ layersRef.value = insert(layersRef.value, insertPosition, cmd);
+ topIdxRef.value += 1;
+ selectedIdxRef.value = insertPosition;
+ },
+ abort() {
+ currentRef.value = null;
+ },
+ };
+}
diff --git a/apps/scishop/src/data/useDrawStateProvider.ts b/apps/scishop/src/data/useDrawStateProvider.ts
new file mode 100644
index 0000000..bc49e87
--- /dev/null
+++ b/apps/scishop/src/data/useDrawStateProvider.ts
@@ -0,0 +1,59 @@
+import { computed, provide, Ref, ref, unref } from 'vue';
+
+import { DrawCodes, DrawMode } from '@4bitlabs/sci0';
+import * as Keys from './keys.ts';
+
+const changeDrawMode = (mode: DrawMode, flag: DrawMode, enabled: boolean) =>
+ enabled ? mode | flag : mode & ~flag;
+
+const ModePosition: Record<
+ DrawMode.Visual | DrawMode.Priority | DrawMode.Control,
+ 1 | 2 | 3
+> = {
+ [DrawMode.Visual]: 1,
+ [DrawMode.Priority]: 2,
+ [DrawMode.Control]: 3,
+};
+
+const codeComputed = (
+ source: Ref<[DrawMode, ...DrawCodes]>,
+ mode: DrawMode.Visual | DrawMode.Priority | DrawMode.Control,
+) =>
+ computed({
+ get: () => unref(source)[ModePosition[mode]],
+ set(next) {
+ source.value[0] |= DrawMode.Visual;
+ source.value[ModePosition[mode]] = next;
+ },
+ });
+
+const enabledComputed = (
+ source: Ref<[DrawMode, ...DrawCodes]>,
+ mode: DrawMode.Visual | DrawMode.Priority | DrawMode.Control,
+) =>
+ computed({
+ get: () => (unref(source)[0] & mode) !== 0,
+ set(next) {
+ const prev = unref(source)[0];
+ source.value[0] = changeDrawMode(prev, mode, next);
+ },
+ });
+
+export function useDrawStateProvider() {
+ const drawState = ref<[DrawMode, ...DrawCodes]>([
+ DrawMode.Visual | DrawMode.Priority,
+ 0,
+ 0,
+ 0,
+ ]);
+
+ provide(Keys.drawStateKey, {
+ raw: drawState,
+ visualEnabled: enabledComputed(drawState, DrawMode.Visual),
+ priorityEnabled: enabledComputed(drawState, DrawMode.Priority),
+ controlEnabled: enabledComputed(drawState, DrawMode.Control),
+ visualCode: codeComputed(drawState, DrawMode.Visual),
+ priorityCode: codeComputed(drawState, DrawMode.Priority),
+ controlCode: codeComputed(drawState, DrawMode.Control),
+ });
+}
diff --git a/apps/scishop/src/data/usePaletteProvider.ts b/apps/scishop/src/data/usePaletteProvider.ts
new file mode 100644
index 0000000..3c69c90
--- /dev/null
+++ b/apps/scishop/src/data/usePaletteProvider.ts
@@ -0,0 +1,75 @@
+import { type ShallowRef, type Ref, computed, ref, unref, provide } from 'vue';
+import deepEqual from 'fast-deep-equal';
+
+import { IBM5153Contrast, Palettes } from '@4bitlabs/color';
+import { DrawCommand } from '@4bitlabs/sci0';
+import {
+ DEFAULT_PALETTE_SET,
+ IndexedPaletteSet,
+ PaletteSet,
+ reduceMutations,
+} from '../helpers/palette-helpers.ts';
+import { EditorCommand } from '../models/EditorCommand.ts';
+import { paletteKey } from './keys.ts';
+
+export function usePaletteProvider(deps: {
+ layersRef: ShallowRef;
+ topIdxRef: Ref;
+}) {
+ const { layersRef, topIdxRef } = deps;
+
+ const baseRef = ref(Palettes.TRUE_CGA_PALETTE);
+ const contrastRef = ref(0.4);
+ const variantRef = ref<0 | 1 | 2 | 3>(2);
+
+ const finalRef = computed(() => {
+ const base = unref(baseRef);
+ const contrast = unref(contrastRef);
+ return contrast !== false ? IBM5153Contrast(base, contrast) : base;
+ });
+
+ const paletteMutationsRef = computed<[number, DrawCommand][]>((prev = []) => {
+ const layers = unref(layersRef);
+ const next = Array.from(layers.entries())
+ .filter(
+ ([, it]) => it.type === 'SET_PALETTE' || it.type === 'UPDATE_PALETTE',
+ )
+ .flatMap<[number, DrawCommand]>(([idx, it]) =>
+ it.commands.map((cmd) => [idx, cmd]),
+ );
+ return deepEqual(prev, next) ? prev : next;
+ });
+
+ const paletteSetChangesRef = computed((prev = []) => {
+ const mutations = unref(paletteMutationsRef);
+ const next = reduceMutations(mutations);
+ return deepEqual(prev, next) ? prev : next;
+ });
+
+ function resolvePaletteAtIdx(n: number): PaletteSet {
+ const changes = unref(paletteSetChangesRef);
+ const found = changes.findLast(([idx]) => idx < n);
+ if (!found) return DEFAULT_PALETTE_SET;
+ const [, actual] = found;
+ return actual;
+ }
+
+ const topPaletteSetRef = computed(() =>
+ resolvePaletteAtIdx(unref(topIdxRef)),
+ );
+
+ const currentPaletteRef = computed(
+ () => unref(topPaletteSetRef)[unref(variantRef)],
+ );
+
+ provide(paletteKey, {
+ contrast: contrastRef,
+ baseColors: baseRef,
+ variant: variantRef,
+ // computed
+ finalColors: finalRef,
+ topPaletteSet: topPaletteSetRef,
+ currentPalette: currentPaletteRef,
+ resolvePaletteAtIdx,
+ });
+}
diff --git a/apps/scishop/src/data/useUpdateSelectionFn.ts b/apps/scishop/src/data/useUpdateSelectionFn.ts
new file mode 100644
index 0000000..0f78966
--- /dev/null
+++ b/apps/scishop/src/data/useUpdateSelectionFn.ts
@@ -0,0 +1,32 @@
+import { Ref, type ShallowRef, unref } from 'vue';
+
+import { EditorCommand } from '../models/EditorCommand.ts';
+import { insert, remove } from '../helpers/array-helpers.ts';
+
+export function useUpdateSelectionFn(deps: {
+ layers: ShallowRef;
+ topIdx: Ref;
+ selectedIdx: Ref;
+}) {
+ const { layers, topIdx, selectedIdx } = deps;
+ return function updateSelection(
+ updateFn: (it: EditorCommand) => EditorCommand | null,
+ ): boolean {
+ const idx = unref(selectedIdx);
+ if (idx === null) return false;
+
+ const cmd = layers.value[idx];
+ if (!cmd) return false;
+
+ const next = updateFn(cmd);
+ if (next === null) {
+ layers.value = remove(layers.value, idx);
+ selectedIdx.value = null;
+ if (idx < unref(topIdx)) topIdx.value -= 1;
+ } else {
+ layers.value = insert(layers.value, idx, next, true);
+ }
+
+ return true;
+ };
+}
diff --git a/apps/scishop/src/helpers/array-helpers.ts b/apps/scishop/src/helpers/array-helpers.ts
new file mode 100644
index 0000000..86c9899
--- /dev/null
+++ b/apps/scishop/src/helpers/array-helpers.ts
@@ -0,0 +1,26 @@
+export const insert = (
+ source: T[],
+ index: number,
+ item: T,
+ replaceAtIndex = false,
+): T[] => [
+ ...source.slice(0, index),
+ item,
+ ...source.slice(replaceAtIndex ? index + 1 : index),
+];
+
+export const insertN = (
+ source: T[],
+ index: number,
+ items: T[],
+ replace = 0,
+): T[] => [
+ ...source.slice(0, index),
+ ...items,
+ ...source.slice(index + Math.max(0, replace)),
+];
+
+export const remove = (source: T[], index: number, count: number = 1) => [
+ ...source.slice(0, index),
+ ...source.slice(index + count),
+];
diff --git a/apps/scishop/src/helpers/clamp.ts b/apps/scishop/src/helpers/clamp.ts
new file mode 100644
index 0000000..f9428de
--- /dev/null
+++ b/apps/scishop/src/helpers/clamp.ts
@@ -0,0 +1,2 @@
+export const clamp = (x: number, lower: number = 0, upper: number = 1) =>
+ Math.max(Math.min(x, upper), lower);
diff --git a/apps/scishop/src/helpers/command-helpers.ts b/apps/scishop/src/helpers/command-helpers.ts
new file mode 100644
index 0000000..5f54625
--- /dev/null
+++ b/apps/scishop/src/helpers/command-helpers.ts
@@ -0,0 +1,158 @@
+import {
+ DrawCommandStruct,
+ DrawCommand,
+ FillCommand,
+ PolylineCommand,
+} from '@4bitlabs/sci0';
+import {
+ distanceBetween,
+ project,
+ squaredDistanceBetween,
+ Vec2,
+} from '@4bitlabs/vec2';
+import { insert } from './array-helpers.ts';
+import { getSegments } from './polygons.ts';
+
+export const extractVertices = (
+ cmd: DrawCommandStruct,
+): Vec2[] => {
+ const [type] = cmd;
+ switch (type) {
+ case 'PLINE': {
+ const [, , ...vertices] = cmd;
+ return vertices;
+ }
+ case 'BRUSH':
+ case 'FILL': {
+ const [, , vertex] = cmd;
+ return [vertex];
+ }
+ default:
+ return [];
+ }
+};
+
+export type ClosestPointState = [dSqrd: number, idx: number];
+export const findClosestPointTo = (
+ vertices: Readonly[],
+ position: Readonly,
+ initial: ClosestPointState = [Infinity, NaN],
+): ClosestPointState =>
+ vertices.reduce((state, point, pIdx) => {
+ const [prevD2] = state;
+ const thisD2 = squaredDistanceBetween(position, point);
+ return thisD2 < prevD2 ? [thisD2, pIdx] : state;
+ }, initial);
+
+export const anyPointCloseTo = (
+ vertices: Readonly[],
+ position: Readonly,
+ proximity: number,
+): boolean => {
+ const [result] = findClosestPointTo(vertices, position);
+ return Math.sqrt(result) <= proximity;
+};
+
+export type FindState = [dSqrd: number, commandIdx: number, pointIdx: number];
+export const findClosestPointIn = (
+ commands: DrawCommand[],
+ position: Readonly,
+): FindState =>
+ commands.reduce(
+ (state, cmd, cmdIdx) => {
+ const [type] = cmd;
+ if (type !== 'PLINE' && type !== 'FILL') return state;
+ const [prevD2] = state;
+ const [nextD2, pIdx] = findClosestPointTo(
+ extractVertices(cmd).map(([x, y]) => [x + 0.5, y + 0.5]),
+ position,
+ );
+ return nextD2 < prevD2 ? [nextD2, cmdIdx, pIdx] : state;
+ },
+ [Infinity, NaN, NaN],
+ );
+
+export type FindResult = [commandIdx: number, pointIdx: number];
+export function nearestPointWithRange(
+ commands: DrawCommand[],
+ position: Readonly,
+ range: number,
+): FindResult | null {
+ const result = findClosestPointIn(commands, position);
+
+ const [distSqrd, ...rest] = result;
+ if (!Number.isFinite(distSqrd)) return null;
+
+ const dist = Math.sqrt(distSqrd);
+ if (dist > range) return null;
+
+ return rest;
+}
+
+export const moveLineVertex = (
+ [type, options, ...verts]: PolylineCommand,
+ idx: number,
+ pos: [number, number],
+): PolylineCommand => [type, options, ...insert(verts, idx, pos, true)];
+
+export const moveFillVertex = (
+ [type, options]: FillCommand,
+ pos: [number, number],
+): FillCommand => [type, options, pos];
+
+export type PointAlongPathResult = [
+ cmdIdx: number,
+ i0: number,
+ i1: number,
+ point: Vec2,
+];
+export function pointAlongPaths(
+ commands: DrawCommand[],
+ position: Readonly,
+ range: number,
+): PointAlongPathResult | null {
+ for (let cmdIdx = 0; cmdIdx < commands.length; cmdIdx++) {
+ const cmd = commands[cmdIdx];
+ const [type] = cmd;
+ if (type !== 'PLINE') continue;
+
+ const points = extractVertices(cmd).map(([x, y]) => [
+ x + 0.5,
+ y + 0.5,
+ ]);
+ for (const segment of getSegments(points)) {
+ const [v0, v1, i0, i1] = segment;
+ const p = project(v0, v1, position);
+ const dist = distanceBetween(p, position);
+ if (dist < range) {
+ return [cmdIdx, i0, i1, p];
+ }
+ }
+ }
+
+ return null;
+}
+
+export function getVertexFrom(
+ commands: DrawCommand[],
+ cmdIdx: number,
+ vertexIdx: number,
+) {
+ const cmd = commands[cmdIdx];
+ if (!cmd) return null;
+ const [type] = cmd;
+ if (type !== 'PLINE' && type !== 'FILL') return null;
+ const points = extractVertices(cmd);
+ return points[vertexIdx];
+}
+
+export function mustGetVertexFrom(
+ commands: DrawCommand[],
+ cmdIdx: number,
+ vertexIdx: number,
+) {
+ const result = getVertexFrom(commands, cmdIdx, vertexIdx);
+ if (!result)
+ throw new Error(`Command does not contain [${cmdIdx}/${vertexIdx}]`);
+ return result;
+}
diff --git a/apps/scishop/src/helpers/exhaustive.ts b/apps/scishop/src/helpers/exhaustive.ts
new file mode 100644
index 0000000..ae3ead0
--- /dev/null
+++ b/apps/scishop/src/helpers/exhaustive.ts
@@ -0,0 +1,3 @@
+export function exhaustive(msg: string, _: never): never {
+ throw new Error(`${msg}: ${JSON.stringify(_)}`);
+}
diff --git a/apps/scishop/src/helpers/getContext.ts b/apps/scishop/src/helpers/getContext.ts
new file mode 100644
index 0000000..44bc419
--- /dev/null
+++ b/apps/scishop/src/helpers/getContext.ts
@@ -0,0 +1,24 @@
+type Canvas = HTMLCanvasElement | OffscreenCanvas;
+type Context = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+const cache = new WeakMap]