@weasel-js/loupe 1.4.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +11 -2
- package/dist/index.js +21 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -52,14 +52,23 @@ interface LoupeSurface {
|
|
|
52
52
|
gone(): boolean;
|
|
53
53
|
/** Something the painter must redraw for has changed. */
|
|
54
54
|
changed(): void;
|
|
55
|
+
/**
|
|
56
|
+
* Run `fn` after each frame the surface lands; returns an unsubscribe.
|
|
57
|
+
* Implement it when `sample` reads pixels that only show an aim once a
|
|
58
|
+
* frame has painted — a framebuffer read back. The model then samples the
|
|
59
|
+
* aim on the next landed frame, so `color` settles a frame after the aim.
|
|
60
|
+
* Omit it when `sample` can answer for a fresh aim right away.
|
|
61
|
+
*/
|
|
62
|
+
subscribeFrame?(fn: () => void): () => void;
|
|
55
63
|
}
|
|
56
64
|
/** Options for {@link createLoupeModel}. */
|
|
57
65
|
interface LoupeModelOptions {
|
|
58
66
|
surface: LoupeSurface;
|
|
59
67
|
mode?: LoupeMode;
|
|
60
|
-
/** Magnification. Default 8.
|
|
68
|
+
/** Magnification. Default 8. Always positive and finite: a factor that is
|
|
69
|
+
* not becomes `ZOOM_FLOOR`, the same rule a view's zoom follows. */
|
|
61
70
|
factor?: number;
|
|
62
|
-
/** Bounds `setFactor` clamps to. Unset means
|
|
71
|
+
/** Bounds `setFactor` clamps to. Unset means bounded only by that rule. */
|
|
63
72
|
minFactor?: number;
|
|
64
73
|
maxFactor?: number;
|
|
65
74
|
/** Called with the hex colour under the aim point whenever it changes. */
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { normalizeZoom } from '@weasel-js/core';
|
|
2
|
+
|
|
3
|
+
// src/model.ts
|
|
4
|
+
|
|
1
5
|
// src/geometry.ts
|
|
2
6
|
function loupeInnerView(target, outer, rect, factor) {
|
|
3
7
|
const scale = { x: outer.scale.x * factor, y: outer.scale.y * factor };
|
|
@@ -18,25 +22,32 @@ function loupeSourcePoint(p, rect, aim, factor) {
|
|
|
18
22
|
function createLoupeModel(opts) {
|
|
19
23
|
const { surface } = opts;
|
|
20
24
|
let mode = opts.mode ?? "vector";
|
|
21
|
-
let factor = opts.factor ?? 8;
|
|
25
|
+
let factor = normalizeZoom(opts.factor ?? 8);
|
|
22
26
|
let aim = { x: 0, y: 0 };
|
|
23
27
|
let color = null;
|
|
24
28
|
let disposed = false;
|
|
25
|
-
|
|
29
|
+
let aimUnsampled = false;
|
|
30
|
+
const clamp = (n) => normalizeZoom(Math.min(
|
|
26
31
|
opts.maxFactor ?? Number.POSITIVE_INFINITY,
|
|
27
32
|
Math.max(opts.minFactor ?? Number.NEGATIVE_INFINITY, n)
|
|
28
|
-
);
|
|
29
|
-
const teardown = () => {
|
|
30
|
-
if (disposed) return;
|
|
31
|
-
disposed = true;
|
|
32
|
-
opts.onDispose?.();
|
|
33
|
-
};
|
|
33
|
+
));
|
|
34
34
|
const sampleColor = () => {
|
|
35
35
|
const hex = surface.sample(aim);
|
|
36
36
|
if (hex === null || hex === color) return;
|
|
37
37
|
color = hex;
|
|
38
38
|
opts.onColorChange?.(hex);
|
|
39
39
|
};
|
|
40
|
+
const unsubscribeFrame = surface.subscribeFrame?.(() => {
|
|
41
|
+
if (!aimUnsampled) return;
|
|
42
|
+
aimUnsampled = false;
|
|
43
|
+
sampleColor();
|
|
44
|
+
});
|
|
45
|
+
const teardown = () => {
|
|
46
|
+
if (disposed) return;
|
|
47
|
+
disposed = true;
|
|
48
|
+
unsubscribeFrame?.();
|
|
49
|
+
opts.onDispose?.();
|
|
50
|
+
};
|
|
40
51
|
return {
|
|
41
52
|
get mode() {
|
|
42
53
|
return mode;
|
|
@@ -69,7 +80,8 @@ function createLoupeModel(opts) {
|
|
|
69
80
|
if (surface.hidden()) return;
|
|
70
81
|
if (surface.covers(p)) return;
|
|
71
82
|
aim = p;
|
|
72
|
-
|
|
83
|
+
if (unsubscribeFrame) aimUnsampled = true;
|
|
84
|
+
else sampleColor();
|
|
73
85
|
surface.changed();
|
|
74
86
|
},
|
|
75
87
|
pick(p) {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/geometry.ts","../src/model.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { View } from '@weasel-js/core';\n\n/** A point on the magnified surface, in its own CSS pixels. */\nexport interface LoupePoint {\n x: number;\n y: number;\n}\n\n/** The lens' rectangle on that surface. */\nexport interface LoupeRect extends LoupePoint {\n w: number;\n h: number;\n}\n\n/**\n * The inner `View` a loupe renders its content through: the outer view's\n * magnification times `factor`, positioned so `target` sits at the center of\n * a viewport the size of `rect`.\n */\nexport function loupeInnerView(\n target: LoupePoint,\n outer: View,\n rect: LoupeRect,\n factor: number,\n): View {\n const scale = { x: outer.scale.x * factor, y: outer.scale.y * factor };\n return {\n x: target.x - rect.w / 2 / scale.x,\n y: target.y - rect.h / 2 / scale.y,\n scale,\n };\n}\n\n/**\n * Where on the outer surface a point inside the lens is looking, in\n * screen-space CSS px.\n *\n * The inverse of {@link loupeInnerView}: the lens centers `aim` in `rect` and\n * magnifies by `factor`, and the outer view's own scale cancels out of the\n * round trip — so this needs no `View`.\n */\nexport function loupeSourcePoint(\n p: LoupePoint,\n rect: LoupeRect,\n aim: LoupePoint,\n factor: number,\n): LoupePoint {\n return {\n x: aim.x + (p.x - (rect.x + rect.w / 2)) / factor,\n y: aim.y + (p.y - (rect.y + rect.h / 2)) / factor,\n };\n}\n","import { type LoupePoint, type LoupeRect, loupeSourcePoint } from './geometry';\n\n/** How a loupe magnifies. `'vector'` re-renders the source through a zoomed-in\n * view, so content stays sharp at any factor; `'pixel'` blows up the actual\n * pixels the surface presented. */\nexport type LoupeMode = 'vector' | 'pixel';\n\n/**\n * What a loupe needs from the surface it magnifies. A painter implements it,\n * and the model asks nothing about how the lens is drawn — only where it is,\n * what colour is under a point, and whether anyone can still see it.\n */\nexport interface LoupeSurface {\n /** The lens' rectangle, or `null` while it has none. */\n lens(): LoupeRect | null;\n /**\n * Does the lens itself cover this point? A stationary lens does over its own\n * frame, which is what the freeze rule below is for; a lens that follows the\n * pointer never covers it and answers `false`.\n */\n covers(p: LoupePoint): boolean;\n /** Hex colour at a surface point, or `null` when the surface cannot say. */\n sample(p: LoupePoint): string | null;\n /** Is the lens off screen? Aims are ignored while it is. */\n hidden(): boolean;\n /** Is the lens gone for good? The model tears itself down when it is. */\n gone(): boolean;\n /** Something the painter must redraw for has changed. */\n changed(): void;\n}\n\n/** Options for {@link createLoupeModel}. */\nexport interface LoupeModelOptions {\n surface: LoupeSurface;\n mode?: LoupeMode;\n /** Magnification. Default 8. */\n factor?: number;\n /** Bounds `setFactor` clamps to. Unset means unclamped. */\n minFactor?: number;\n maxFactor?: number;\n /** Called with the hex colour under the aim point whenever it changes. */\n onColorChange?: (hex: string) => void;\n /** Called when a pick lands — an eyedropper on the magnified surface. Where\n * the colour goes is the consumer's business. */\n onPick?: (hex: string) => void;\n /** Called when the model tears itself down because the surface reported the\n * lens gone, so the painter can drop what it was holding. */\n onDispose?: () => void;\n}\n\n/** Where a loupe is aimed, how far it magnifies, and what colour it is over. */\nexport interface LoupeModel {\n readonly mode: LoupeMode;\n readonly factor: number;\n /** Last aimed point, in the surface's CSS px. */\n readonly aim: LoupePoint;\n /** Hex colour under the aim point. `null` until something answers. */\n readonly color: string | null;\n setMode(mode: LoupeMode): void;\n setFactor(factor: number): void;\n /** Aim at a surface point. Ignored while the lens covers it, is hidden, or\n * the model is disposed. */\n aimAt(p: LoupePoint): void;\n /** Sample what the lens shows at `p`, a point inside it, and report that to\n * `onPick`. Omit `p` to pick at the aim point. Leaves the aim, and `color`,\n * alone. */\n pick(p?: LoupePoint): string | null;\n dispose(): void;\n}\n\n/**\n * Build the loupe's model over a surface. It holds the state a magnifier has —\n * aim, factor, mode, colour — and none of the drawing.\n */\nexport function createLoupeModel(opts: LoupeModelOptions): LoupeModel {\n const { surface } = opts;\n let mode: LoupeMode = opts.mode ?? 'vector';\n let factor = opts.factor ?? 8;\n let aim: LoupePoint = { x: 0, y: 0 };\n let color: string | null = null;\n let disposed = false;\n\n const clamp = (n: number): number =>\n Math.min(opts.maxFactor ?? Number.POSITIVE_INFINITY,\n Math.max(opts.minFactor ?? Number.NEGATIVE_INFINITY, n));\n\n const teardown = () => {\n if (disposed) return;\n disposed = true;\n opts.onDispose?.();\n };\n\n const sampleColor = () => {\n const hex = surface.sample(aim);\n if (hex === null || hex === color) return;\n color = hex;\n opts.onColorChange?.(hex);\n };\n\n return {\n get mode() { return mode; },\n get factor() { return factor; },\n get aim() { return aim; },\n get color() { return color; },\n\n setMode(next) {\n if (disposed || next === mode) return;\n mode = next;\n surface.changed();\n },\n\n setFactor(next) {\n if (disposed) return;\n factor = clamp(next);\n surface.changed();\n },\n\n aimAt(p) {\n if (disposed) return;\n // A lens can be taken away by whatever owns it, without coming back\n // through `dispose`. Follow it down rather than keep sampling for a\n // magnifier nobody can see.\n if (surface.gone()) { teardown(); return; }\n if (surface.hidden()) return;\n if (surface.covers(p)) return;\n aim = p;\n sampleColor();\n surface.changed();\n },\n\n pick(p) {\n if (disposed) return null;\n let at = aim;\n if (p) {\n const rect = surface.lens();\n if (!rect) return null;\n at = loupeSourcePoint(p, rect, aim, factor);\n // The lens is part of the picture the surface presents. A point near\n // its frame maps back underneath it, and sampling there reports the\n // lens' own chrome as artwork.\n if (surface.covers(at)) return null;\n }\n const hex = surface.sample(at);\n if (hex !== null) opts.onPick?.(hex);\n return hex;\n },\n\n dispose: teardown,\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/geometry.ts","../src/model.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { View } from '@weasel-js/core';\n\n/** A point on the magnified surface, in its own CSS pixels. */\nexport interface LoupePoint {\n x: number;\n y: number;\n}\n\n/** The lens' rectangle on that surface. */\nexport interface LoupeRect extends LoupePoint {\n w: number;\n h: number;\n}\n\n/**\n * The inner `View` a loupe renders its content through: the outer view's\n * magnification times `factor`, positioned so `target` sits at the center of\n * a viewport the size of `rect`.\n */\nexport function loupeInnerView(\n target: LoupePoint,\n outer: View,\n rect: LoupeRect,\n factor: number,\n): View {\n const scale = { x: outer.scale.x * factor, y: outer.scale.y * factor };\n return {\n x: target.x - rect.w / 2 / scale.x,\n y: target.y - rect.h / 2 / scale.y,\n scale,\n };\n}\n\n/**\n * Where on the outer surface a point inside the lens is looking, in\n * screen-space CSS px.\n *\n * The inverse of {@link loupeInnerView}: the lens centers `aim` in `rect` and\n * magnifies by `factor`, and the outer view's own scale cancels out of the\n * round trip — so this needs no `View`.\n */\nexport function loupeSourcePoint(\n p: LoupePoint,\n rect: LoupeRect,\n aim: LoupePoint,\n factor: number,\n): LoupePoint {\n return {\n x: aim.x + (p.x - (rect.x + rect.w / 2)) / factor,\n y: aim.y + (p.y - (rect.y + rect.h / 2)) / factor,\n };\n}\n","import { normalizeZoom } from '@weasel-js/core';\nimport { type LoupePoint, type LoupeRect, loupeSourcePoint } from './geometry';\n\n/** How a loupe magnifies. `'vector'` re-renders the source through a zoomed-in\n * view, so content stays sharp at any factor; `'pixel'` blows up the actual\n * pixels the surface presented. */\nexport type LoupeMode = 'vector' | 'pixel';\n\n/**\n * What a loupe needs from the surface it magnifies. A painter implements it,\n * and the model asks nothing about how the lens is drawn — only where it is,\n * what colour is under a point, and whether anyone can still see it.\n */\nexport interface LoupeSurface {\n /** The lens' rectangle, or `null` while it has none. */\n lens(): LoupeRect | null;\n /**\n * Does the lens itself cover this point? A stationary lens does over its own\n * frame, which is what the freeze rule below is for; a lens that follows the\n * pointer never covers it and answers `false`.\n */\n covers(p: LoupePoint): boolean;\n /** Hex colour at a surface point, or `null` when the surface cannot say. */\n sample(p: LoupePoint): string | null;\n /** Is the lens off screen? Aims are ignored while it is. */\n hidden(): boolean;\n /** Is the lens gone for good? The model tears itself down when it is. */\n gone(): boolean;\n /** Something the painter must redraw for has changed. */\n changed(): void;\n /**\n * Run `fn` after each frame the surface lands; returns an unsubscribe.\n * Implement it when `sample` reads pixels that only show an aim once a\n * frame has painted — a framebuffer read back. The model then samples the\n * aim on the next landed frame, so `color` settles a frame after the aim.\n * Omit it when `sample` can answer for a fresh aim right away.\n */\n subscribeFrame?(fn: () => void): () => void;\n}\n\n/** Options for {@link createLoupeModel}. */\nexport interface LoupeModelOptions {\n surface: LoupeSurface;\n mode?: LoupeMode;\n /** Magnification. Default 8. Always positive and finite: a factor that is\n * not becomes `ZOOM_FLOOR`, the same rule a view's zoom follows. */\n factor?: number;\n /** Bounds `setFactor` clamps to. Unset means bounded only by that rule. */\n minFactor?: number;\n maxFactor?: number;\n /** Called with the hex colour under the aim point whenever it changes. */\n onColorChange?: (hex: string) => void;\n /** Called when a pick lands — an eyedropper on the magnified surface. Where\n * the colour goes is the consumer's business. */\n onPick?: (hex: string) => void;\n /** Called when the model tears itself down because the surface reported the\n * lens gone, so the painter can drop what it was holding. */\n onDispose?: () => void;\n}\n\n/** Where a loupe is aimed, how far it magnifies, and what colour it is over. */\nexport interface LoupeModel {\n readonly mode: LoupeMode;\n readonly factor: number;\n /** Last aimed point, in the surface's CSS px. */\n readonly aim: LoupePoint;\n /** Hex colour under the aim point. `null` until something answers. */\n readonly color: string | null;\n setMode(mode: LoupeMode): void;\n setFactor(factor: number): void;\n /** Aim at a surface point. Ignored while the lens covers it, is hidden, or\n * the model is disposed. */\n aimAt(p: LoupePoint): void;\n /** Sample what the lens shows at `p`, a point inside it, and report that to\n * `onPick`. Omit `p` to pick at the aim point. Leaves the aim, and `color`,\n * alone. */\n pick(p?: LoupePoint): string | null;\n dispose(): void;\n}\n\n/**\n * Build the loupe's model over a surface. It holds the state a magnifier has —\n * aim, factor, mode, colour — and none of the drawing.\n */\nexport function createLoupeModel(opts: LoupeModelOptions): LoupeModel {\n const { surface } = opts;\n let mode: LoupeMode = opts.mode ?? 'vector';\n let factor = normalizeZoom(opts.factor ?? 8);\n let aim: LoupePoint = { x: 0, y: 0 };\n let color: string | null = null;\n let disposed = false;\n let aimUnsampled = false;\n\n const clamp = (n: number): number =>\n normalizeZoom(Math.min(opts.maxFactor ?? Number.POSITIVE_INFINITY,\n Math.max(opts.minFactor ?? Number.NEGATIVE_INFINITY, n)));\n\n const sampleColor = () => {\n const hex = surface.sample(aim);\n if (hex === null || hex === color) return;\n color = hex;\n opts.onColorChange?.(hex);\n };\n\n const unsubscribeFrame = surface.subscribeFrame?.(() => {\n if (!aimUnsampled) return;\n aimUnsampled = false;\n sampleColor();\n });\n\n const teardown = () => {\n if (disposed) return;\n disposed = true;\n unsubscribeFrame?.();\n opts.onDispose?.();\n };\n\n return {\n get mode() { return mode; },\n get factor() { return factor; },\n get aim() { return aim; },\n get color() { return color; },\n\n setMode(next) {\n if (disposed || next === mode) return;\n mode = next;\n surface.changed();\n },\n\n setFactor(next) {\n if (disposed) return;\n factor = clamp(next);\n surface.changed();\n },\n\n aimAt(p) {\n if (disposed) return;\n // A lens can be taken away by whatever owns it, without coming back\n // through `dispose`. Follow it down rather than keep sampling for a\n // magnifier nobody can see.\n if (surface.gone()) { teardown(); return; }\n if (surface.hidden()) return;\n if (surface.covers(p)) return;\n aim = p;\n if (unsubscribeFrame) aimUnsampled = true;\n else sampleColor();\n surface.changed();\n },\n\n pick(p) {\n if (disposed) return null;\n let at = aim;\n if (p) {\n const rect = surface.lens();\n if (!rect) return null;\n at = loupeSourcePoint(p, rect, aim, factor);\n // The lens is part of the picture the surface presents. A point near\n // its frame maps back underneath it, and sampling there reports the\n // lens' own chrome as artwork.\n if (surface.covers(at)) return null;\n }\n const hex = surface.sample(at);\n if (hex !== null) opts.onPick?.(hex);\n return hex;\n },\n\n dispose: teardown,\n };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/loupe",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Surface-free magnifier model: where a loupe is aimed, how far it magnifies, and what colour it is over. Painters live with the surfaces they draw on.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"node": ">=22"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@weasel-js/core": "1.
|
|
32
|
+
"@weasel-js/core": "1.5.0"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|