gds-lens 0.1.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/CHANGELOG.md +167 -0
- package/LICENCE.md +21 -0
- package/README.md +372 -0
- package/THIRD-PARTY-LICENSES.md +261 -0
- package/dist/esm/gds-lens.js +4731 -0
- package/dist/inline-wasm/gds-lens-engine.js +0 -0
- package/dist/inline-wasm/gds-lens-host.js +82 -0
- package/dist/inline-wasm/gds-lens-worker.js +118 -0
- package/dist/inline-wasm/gds-lens.html +29 -0
- package/dist/inline-wasm/gds-lens.js +4705 -0
- package/dist/web/gds-lens-engine.js +2 -0
- package/dist/web/gds-lens-engine.wasm +0 -0
- package/dist/web/gds-lens-host.js +82 -0
- package/dist/web/gds-lens-worker.js +118 -0
- package/dist/web/gds-lens.html +29 -0
- package/dist/web/gds-lens.js +4705 -0
- package/package.json +113 -0
- package/src/cell-search.js +88 -0
- package/src/coord-parse.js +50 -0
- package/src/engine-source.esm.js +49 -0
- package/src/engine-source.js +20 -0
- package/src/gds-lens.js +175 -0
- package/src/hosts/browser.js +156 -0
- package/src/layout-bytes.js +143 -0
- package/src/load-errors.js +64 -0
- package/src/marker-parsers.js +672 -0
- package/src/mount-target.js +23 -0
- package/src/viewer-shell.html +105 -0
- package/src/viewer.css +469 -0
- package/src/viewer.html +29 -0
- package/src/viewer.js +2688 -0
- package/src/wasm-worker.js +157 -0
- package/types/cell-search.d.ts +33 -0
- package/types/coord-parse.d.ts +11 -0
- package/types/gds-lens.d.ts +135 -0
- package/types/hosts-browser.d.ts +13 -0
- package/types/layout-bytes.d.ts +49 -0
- package/types/load-errors.d.ts +27 -0
- package/types/parsers.d.ts +84 -0
package/package.json
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gds-lens",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GDSII & OASIS layout parsing and WebGL2 rendering for the browser",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Ethan Lowenthal",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"gdsii",
|
|
9
|
+
"gds",
|
|
10
|
+
"oasis",
|
|
11
|
+
"oas",
|
|
12
|
+
"layout",
|
|
13
|
+
"layout-viewer",
|
|
14
|
+
"webgl",
|
|
15
|
+
"wasm",
|
|
16
|
+
"lyp",
|
|
17
|
+
"vlsi",
|
|
18
|
+
"eda",
|
|
19
|
+
"ic-layout",
|
|
20
|
+
"chip-design",
|
|
21
|
+
"semiconductor",
|
|
22
|
+
"photonics",
|
|
23
|
+
"drc",
|
|
24
|
+
"lvs",
|
|
25
|
+
"lyrdb"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/EthanLowenthal/GDS-Lens"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/EthanLowenthal/GDS-Lens#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/EthanLowenthal/GDS-Lens/issues"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build:wasm": "npm run build:wasm:web && npm run build:wasm:inline && npm run build:wasm:esm",
|
|
37
|
+
"build:wasm:web": "emcmake cmake -S src/wasm -B src/wasm/build/web -DGDS_LENS_INLINE_WASM=OFF && cmake --build src/wasm/build/web",
|
|
38
|
+
"build:wasm:inline": "emcmake cmake -S src/wasm -B src/wasm/build/inline -DGDS_LENS_INLINE_WASM=ON && cmake --build src/wasm/build/inline",
|
|
39
|
+
"build": "node scripts/build-webview.mjs",
|
|
40
|
+
"watch": "node scripts/build-webview.mjs --watch",
|
|
41
|
+
"test": "node --test test/*.test.js",
|
|
42
|
+
"lint": "eslint .",
|
|
43
|
+
"check:dist": "node scripts/check-dist.mjs",
|
|
44
|
+
"check:types": "tsc --noEmit -p test/types/tsconfig.json",
|
|
45
|
+
"prepublishOnly": "npm run lint && npm test && npm run check:dist && npm run check:package",
|
|
46
|
+
"check:package": "node scripts/check-package.mjs",
|
|
47
|
+
"build:wasm:esm": "emcmake cmake -S src/wasm -B src/wasm/build/esm -DGDS_LENS_ESM=ON && cmake --build src/wasm/build/esm"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@eslint/js": "^10.0.1",
|
|
51
|
+
"@xmldom/xmldom": "^0.9.10",
|
|
52
|
+
"esbuild": "^0.28.2",
|
|
53
|
+
"eslint": "^10.5.0",
|
|
54
|
+
"globals": "^17.11.0",
|
|
55
|
+
"lil-gui": "^0.21.0",
|
|
56
|
+
"playwright": "^1.62.1",
|
|
57
|
+
"typescript": "^7.0.2"
|
|
58
|
+
},
|
|
59
|
+
"files": [
|
|
60
|
+
"types/",
|
|
61
|
+
"dist/",
|
|
62
|
+
"src/",
|
|
63
|
+
"!src/wasm",
|
|
64
|
+
"LICENCE.md",
|
|
65
|
+
"THIRD-PARTY-LICENSES.md",
|
|
66
|
+
"CHANGELOG.md",
|
|
67
|
+
"README.md"
|
|
68
|
+
],
|
|
69
|
+
"types": "./types/gds-lens.d.ts",
|
|
70
|
+
"exports": {
|
|
71
|
+
".": {
|
|
72
|
+
"types": "./types/gds-lens.d.ts",
|
|
73
|
+
"default": "./dist/esm/gds-lens.js"
|
|
74
|
+
},
|
|
75
|
+
"./parsers": {
|
|
76
|
+
"types": "./types/parsers.d.ts",
|
|
77
|
+
"default": "./src/marker-parsers.js"
|
|
78
|
+
},
|
|
79
|
+
"./cell-search": {
|
|
80
|
+
"types": "./types/cell-search.d.ts",
|
|
81
|
+
"default": "./src/cell-search.js"
|
|
82
|
+
},
|
|
83
|
+
"./coord-parse": {
|
|
84
|
+
"types": "./types/coord-parse.d.ts",
|
|
85
|
+
"default": "./src/coord-parse.js"
|
|
86
|
+
},
|
|
87
|
+
"./layout-bytes": {
|
|
88
|
+
"types": "./types/layout-bytes.d.ts",
|
|
89
|
+
"default": "./src/layout-bytes.js"
|
|
90
|
+
},
|
|
91
|
+
"./load-errors": {
|
|
92
|
+
"types": "./types/load-errors.d.ts",
|
|
93
|
+
"default": "./src/load-errors.js"
|
|
94
|
+
},
|
|
95
|
+
"./hosts/browser": {
|
|
96
|
+
"types": "./types/hosts-browser.d.ts",
|
|
97
|
+
"default": "./src/hosts/browser.js"
|
|
98
|
+
},
|
|
99
|
+
"./web/*": "./dist/web/*",
|
|
100
|
+
"./inline-wasm/*": "./dist/inline-wasm/*",
|
|
101
|
+
"./package.json": "./package.json",
|
|
102
|
+
"./esm/*": "./dist/esm/*"
|
|
103
|
+
},
|
|
104
|
+
"type": "module",
|
|
105
|
+
"engines": {
|
|
106
|
+
"node": ">=20"
|
|
107
|
+
},
|
|
108
|
+
"sideEffects": [
|
|
109
|
+
"./dist/esm/gds-lens.js",
|
|
110
|
+
"./src/gds-lens.js",
|
|
111
|
+
"./dist/**"
|
|
112
|
+
]
|
|
113
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Searching the cell hierarchy: which cells a typed name matches, and how the
|
|
2
|
+
// tree gets down to one of them. This is a standalone script (no imports)
|
|
3
|
+
// loaded two ways, following the same pattern as marker-parsers.js and
|
|
4
|
+
// load-errors.js: the webview pulls it in via a <script> tag, and Node unit
|
|
5
|
+
// tests require() it via the module.exports tail.
|
|
6
|
+
//
|
|
7
|
+
// Both functions work on the hierarchy model parseGdsToLayers hands back (see
|
|
8
|
+
// build_hierarchy in wasm/renderer.cpp): a flat `cells` array of
|
|
9
|
+
// {name, polygons, labels, bbox, refs} where each ref is {cell, count, ...},
|
|
10
|
+
// plus the indices of the top-level cells. Nothing here touches the DOM or the
|
|
11
|
+
// wasm module -- the panel work is in viewer.js.
|
|
12
|
+
|
|
13
|
+
// Indices of every cell whose name contains `query`, case-insensitively, best
|
|
14
|
+
// match first: the exact name, then names that start with the query, then the
|
|
15
|
+
// rest. Ordering matters because the panel only builds the first couple of
|
|
16
|
+
// hundred rows -- typing a cell's full name should not leave it below thirty
|
|
17
|
+
// cells that merely contain it.
|
|
18
|
+
function rankCellMatches(cells, query) {
|
|
19
|
+
const needle = String(query).trim().toLowerCase();
|
|
20
|
+
if (!needle) return [];
|
|
21
|
+
|
|
22
|
+
const exact = [];
|
|
23
|
+
const prefix = [];
|
|
24
|
+
const rest = [];
|
|
25
|
+
for (let i = 0; i < cells.length; i++) {
|
|
26
|
+
const name = cells[i] && typeof cells[i].name === "string" ? cells[i].name : "";
|
|
27
|
+
const at = name.toLowerCase().indexOf(needle);
|
|
28
|
+
if (at < 0) continue;
|
|
29
|
+
// at >= 0 with equal lengths *is* an exact match -- the needle can only
|
|
30
|
+
// have been found at 0.
|
|
31
|
+
if (name.length === needle.length) exact.push(i);
|
|
32
|
+
else if (at === 0) prefix.push(i);
|
|
33
|
+
else rest.push(i);
|
|
34
|
+
}
|
|
35
|
+
return exact.concat(prefix, rest);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// One path of cell indices from a top cell down to `target`, starting at the
|
|
39
|
+
// root and ending at the target itself -- or null if no top cell reaches it
|
|
40
|
+
// (a reference cycle among non-top cells, or a cell the tree's own caps left
|
|
41
|
+
// out of `roots`).
|
|
42
|
+
//
|
|
43
|
+
// Depth-first, so it's *a* path rather than the shortest one: a cell placed in
|
|
44
|
+
// twenty places has twenty paths to it and none is more correct than another,
|
|
45
|
+
// so the first one found is as good an answer as any -- and it's the one the
|
|
46
|
+
// tree reads down to in the order the file placed things.
|
|
47
|
+
//
|
|
48
|
+
// `failed` is what keeps this cheap on a real library. References form a DAG,
|
|
49
|
+
// so without remembering the subtrees that turned out not to contain the
|
|
50
|
+
// target, a design that shares cells widely would re-walk the same ones once
|
|
51
|
+
// per path leading into them. `maxDepth` bounds the recursion, which is what
|
|
52
|
+
// makes a malformed file that closes a reference loop terminate: a cycle's
|
|
53
|
+
// cells are pushed until the limit, then unwound and marked failed.
|
|
54
|
+
function cellPathToTarget(cells, roots, target, maxDepth) {
|
|
55
|
+
if (!cells || !cells[target]) return null;
|
|
56
|
+
const limit = maxDepth > 0 ? maxDepth : 1;
|
|
57
|
+
const failed = new Set();
|
|
58
|
+
const path = [];
|
|
59
|
+
|
|
60
|
+
function walk(index, depth) {
|
|
61
|
+
path.push(index);
|
|
62
|
+
if (index === target) return true;
|
|
63
|
+
// Not "already on the path": `failed` also covers it, since a cell is
|
|
64
|
+
// only marked once its own walk has come back empty.
|
|
65
|
+
if (depth + 1 < limit && !failed.has(index)) {
|
|
66
|
+
const refs = (cells[index] && cells[index].refs) || [];
|
|
67
|
+
for (const ref of refs) {
|
|
68
|
+
if (!cells[ref.cell]) continue;
|
|
69
|
+
if (walk(ref.cell, depth + 1)) return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
path.pop();
|
|
73
|
+
failed.add(index);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const root of (roots || [])) {
|
|
78
|
+
if (!cells[root]) continue;
|
|
79
|
+
if (walk(root, 0)) return path;
|
|
80
|
+
// walk() unwinds its own pushes on failure, but a root that failed at
|
|
81
|
+
// depth 0 leaves nothing to unwind -- clear anyway rather than trusting
|
|
82
|
+
// that from out here.
|
|
83
|
+
path.length = 0;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { rankCellMatches, cellPathToTarget };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Reads a coordinate pair out of whatever text a layout engineer happens to
|
|
2
|
+
// have on the clipboard. Coordinates arrive from outside the viewer all day --
|
|
3
|
+
// a DRC report, a colleague's message, a generator's log -- and each source
|
|
4
|
+
// wraps them differently.
|
|
5
|
+
//
|
|
6
|
+
// This is a standalone script (no imports) following the same pattern as
|
|
7
|
+
// load-errors.js and marker-parsers.js, so it can be require()d from both the
|
|
8
|
+
// extension host (the "Go to Coordinate" command's input box validates with it)
|
|
9
|
+
// and Node unit tests. The webview doesn't need it: the host sends the parsed
|
|
10
|
+
// numbers, not the typed string.
|
|
11
|
+
//
|
|
12
|
+
// The accepted forms are whatever those sources actually produce, which is
|
|
13
|
+
// "x, y" with any of the usual decorations: parentheses, a semicolon or bare
|
|
14
|
+
// whitespace as the separator, and an optional per-number unit. Microns are the
|
|
15
|
+
// default because that's what the readout, the ruler and the .lyrdb files all
|
|
16
|
+
// speak.
|
|
17
|
+
const COORD_UNITS = {
|
|
18
|
+
nm: 1e-3,
|
|
19
|
+
um: 1,
|
|
20
|
+
"µm": 1, // MICRO SIGN
|
|
21
|
+
"μm": 1, // GREEK SMALL LETTER MU -- both are in the wild
|
|
22
|
+
mm: 1e3
|
|
23
|
+
};
|
|
24
|
+
// One number plus an optional unit; the parse takes the first two matches and
|
|
25
|
+
// requires the rest of the string to be separators, so "1 2 3" is rejected
|
|
26
|
+
// rather than silently read as "1 2".
|
|
27
|
+
const COORD_TOKEN = /(-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(nm|um|µm|μm|mm)?/gi;
|
|
28
|
+
// What may surround the two numbers -- punctuation, plus a bare x/y label,
|
|
29
|
+
// since "x=12.5, y=40" is a shape real reports print. Anything else means the
|
|
30
|
+
// string wasn't a coordinate pair, so it's rejected rather than half-read.
|
|
31
|
+
const COORD_FILLER = /^[\s(),;:=xy]*$/i;
|
|
32
|
+
|
|
33
|
+
// Returns {x, y} in microns, or null if the text isn't a coordinate pair.
|
|
34
|
+
function parseCoordinatePair(text) {
|
|
35
|
+
const numbers = [];
|
|
36
|
+
let start = 0;
|
|
37
|
+
let end = 0;
|
|
38
|
+
COORD_TOKEN.lastIndex = 0;
|
|
39
|
+
for (let match = COORD_TOKEN.exec(text); match; match = COORD_TOKEN.exec(text)) {
|
|
40
|
+
numbers.push(parseFloat(match[1]) * (match[2] ? COORD_UNITS[match[2].toLowerCase()] : 1));
|
|
41
|
+
if (numbers.length === 1) start = match.index;
|
|
42
|
+
end = COORD_TOKEN.lastIndex;
|
|
43
|
+
if (numbers.length === 2) break;
|
|
44
|
+
}
|
|
45
|
+
if (numbers.length !== 2 || !numbers.every(Number.isFinite)) return null;
|
|
46
|
+
if (!COORD_FILLER.test(text.slice(0, start)) || !COORD_FILLER.test(text.slice(end))) return null;
|
|
47
|
+
return { x: numbers[0], y: numbers[1] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { parseCoordinatePair };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The bundled ESM build's answer to "where does the engine come from": inlined
|
|
2
|
+
// text, because there is nothing beside a bundled module to fetch.
|
|
3
|
+
//
|
|
4
|
+
// Both the main thread and the parse Worker need Emscripten's module, and a
|
|
5
|
+
// Worker cannot share the main thread's copy -- it is a separate script
|
|
6
|
+
// context. Inlining it twice would put the ~400KB binary in the bundle twice,
|
|
7
|
+
// so it is inlined *once* as text and turned into a blob: URL that both sides
|
|
8
|
+
// load. That is why the main thread imports through a blob rather than
|
|
9
|
+
// importing gds-lens-engine.mjs directly.
|
|
10
|
+
//
|
|
11
|
+
// Both loads therefore need `blob:` in the page's CSP -- script-src for the
|
|
12
|
+
// main thread, worker-src for the Worker. The served payloads do not (only
|
|
13
|
+
// worker-src), which is the one thing this build asks for that they do not.
|
|
14
|
+
//
|
|
15
|
+
// The text import is aliased by scripts/build-webview.mjs to the built
|
|
16
|
+
// gds-lens-engine.mjs; the worker half arrives as a define.
|
|
17
|
+
import gdstkSource from "gds-lens:gdstk-source";
|
|
18
|
+
|
|
19
|
+
// Substituted by scripts/build-webview.mjs: the bundled wasm-worker.js as a
|
|
20
|
+
// string. A define rather than an import because it is built in an earlier
|
|
21
|
+
// pass and never written to disk.
|
|
22
|
+
const workerSource = __GDS_LENS_WORKER_SOURCE__;
|
|
23
|
+
|
|
24
|
+
// One object URL, made on first use and kept: revoking it would break the
|
|
25
|
+
// Worker, which loads from the same URL, and a second blob would be a second
|
|
26
|
+
// copy of half a megabyte in memory.
|
|
27
|
+
let moduleUrl = null;
|
|
28
|
+
function gdstkModuleUrl() {
|
|
29
|
+
if (!moduleUrl) {
|
|
30
|
+
moduleUrl = URL.createObjectURL(new Blob([gdstkSource], { type: "text/javascript" }));
|
|
31
|
+
}
|
|
32
|
+
return moduleUrl;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function loadGdstkFactory() {
|
|
36
|
+
// A dynamic import, not an eval: no 'unsafe-eval' is needed, which is what
|
|
37
|
+
// keeps -sDYNAMIC_EXECUTION=0 worth having.
|
|
38
|
+
const module = await import(/* @vite-ignore */ gdstkModuleUrl());
|
|
39
|
+
return module.default;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Emscripten's ES module output uses import.meta.url, so this has to be loaded
|
|
43
|
+
// as a *module* Worker -- a classic one fails to parse. The factory is a
|
|
44
|
+
// top-level declaration in gdstkSource, so the worker half below simply sees
|
|
45
|
+
// it in scope once the two are concatenated.
|
|
46
|
+
export const workerBundle = {
|
|
47
|
+
type: "module",
|
|
48
|
+
text: () => `${gdstkSource}\n${workerSource}`,
|
|
49
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Where the wasm factory and the parse Worker's script come from.
|
|
2
|
+
//
|
|
3
|
+
// This is the default: the served payloads (dist/web, dist/inline-wasm) load
|
|
4
|
+
// Emscripten's output as a classic <script> before the bundle, so the factory
|
|
5
|
+
// is already a global (from gds-lens-engine.js) and the Worker can fetch the
|
|
6
|
+
// same scripts by URL.
|
|
7
|
+
//
|
|
8
|
+
// The bundled ESM build swaps this file for engine-source.esm.js, which
|
|
9
|
+
// carries both as inlined text instead (see scripts/build-webview.mjs). Every
|
|
10
|
+
// difference between "a payload you serve" and "a module you import" is in
|
|
11
|
+
// these two files, so nothing else has to know which one it is running in.
|
|
12
|
+
|
|
13
|
+
// Emscripten's MODULARIZE output defines this on the global object.
|
|
14
|
+
export function loadGdstkFactory() {
|
|
15
|
+
return Promise.resolve(globalThis.createGdstkModule);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Null means "no inlined script": viewer.js builds the Worker from
|
|
19
|
+
// document-relative URLs instead. See createParseWorker.
|
|
20
|
+
export const workerBundle = null;
|
package/src/gds-lens.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// The <gds-lens> custom element: the package's entry point.
|
|
2
|
+
//
|
|
3
|
+
// Kept apart from viewer.js so that importing this module only *registers* the
|
|
4
|
+
// element. The engine, the wasm module and the WebGL context are all deferred
|
|
5
|
+
// until an element actually connects, which is what makes this a component
|
|
6
|
+
// someone can import into an app rather than a script that takes over the page
|
|
7
|
+
// on load.
|
|
8
|
+
|
|
9
|
+
import { setMountTarget } from "./mount-target.js";
|
|
10
|
+
|
|
11
|
+
// True only in the inline-wasm payload, where gds-lens-engine.js carries the ~400KB
|
|
12
|
+
// wasm binary inline as a raw string -- 66,000-odd non-ASCII bytes. A <script
|
|
13
|
+
// src> with no charset of its own is decoded using the *document's* encoding,
|
|
14
|
+
// so on a page that does not declare UTF-8 those bytes are mangled and the
|
|
15
|
+
// module fails with "WebAssembly.instantiate(): section was shorter than
|
|
16
|
+
// expected size" -- an error that says nothing whatsoever about the encoding
|
|
17
|
+
// that caused it. Say so plainly instead of letting someone lose an afternoon
|
|
18
|
+
// to it.
|
|
19
|
+
//
|
|
20
|
+
// esbuild substitutes this per payload (see scripts/build-webview.mjs), so the
|
|
21
|
+
// warning stays silent in the default build, where the binary is a separate
|
|
22
|
+
// file and the page's encoding is nobody's business. The typeof guard is for
|
|
23
|
+
// running from source through the package's exports map, where nothing has
|
|
24
|
+
// defined it and it should warn.
|
|
25
|
+
const INLINE_WASM = typeof __GDS_LENS_INLINE_WASM__ === "undefined" || __GDS_LENS_INLINE_WASM__;
|
|
26
|
+
|
|
27
|
+
function warnIfNotUtf8() {
|
|
28
|
+
if (!INLINE_WASM) return;
|
|
29
|
+
const encoding = document.characterSet || document.charset;
|
|
30
|
+
if (encoding && encoding.toUpperCase() !== "UTF-8") {
|
|
31
|
+
console.error(
|
|
32
|
+
`[GDS] this document is ${encoding}, not UTF-8. This build of gds-lens ` +
|
|
33
|
+
"embeds its WebAssembly binary in a script that must be decoded as " +
|
|
34
|
+
'UTF-8; it will fail to load. Add <meta charset="UTF-8"> to the page, ' +
|
|
35
|
+
"serve the scripts as text/javascript; charset=utf-8, or use the " +
|
|
36
|
+
"default build, which keeps the binary in a separate file."
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// One viewer at a time. renderer.cpp holds its state (GL program, VAO, uniform
|
|
42
|
+
// locations, camera, layer table) in file-scope globals, so two live elements
|
|
43
|
+
// cannot share the module and would silently fight over the same state.
|
|
44
|
+
// Refusing the second with a visible message is the honest failure until that
|
|
45
|
+
// state is threaded through a context.
|
|
46
|
+
//
|
|
47
|
+
// "At a time" rather than "per page load", though: the element that holds the
|
|
48
|
+
// claim gives it up when it leaves the DOM, and the next one to connect gets
|
|
49
|
+
// the existing engine moved into it. Without that, a framework re-render or an
|
|
50
|
+
// SPA route change -- anything that recreates the node -- left every
|
|
51
|
+
// subsequent <gds-lens> permanently refusing, since the engine's module body
|
|
52
|
+
// had already run and could not run again.
|
|
53
|
+
let mounted = null;
|
|
54
|
+
|
|
55
|
+
// The loaded engine module, kept across mounts. Populated on the first mount
|
|
56
|
+
// and never torn down: it owns the wasm instance and the GL context, and a
|
|
57
|
+
// module body cannot be re-run.
|
|
58
|
+
let engine = null;
|
|
59
|
+
|
|
60
|
+
// Importing this module must not need a DOM. `import "gds-lens"` is the
|
|
61
|
+
// documented entry point, and every SSR framework -- Next, Nuxt, Astro,
|
|
62
|
+
// SvelteKit -- evaluates that import on the server first, where there is no
|
|
63
|
+
// HTMLElement. A class declaration is evaluated at import time, so extending
|
|
64
|
+
// it directly threw a ReferenceError before anything had even tried to use the
|
|
65
|
+
// component. Extending a stub instead moves the failure to the only place it
|
|
66
|
+
// can matter -- constructing an element, which nothing but a browser does --
|
|
67
|
+
// and leaves the import a no-op on the server, which is what a consumer's
|
|
68
|
+
// bundler and framework both expect of a custom element.
|
|
69
|
+
const ElementBase = typeof HTMLElement === "undefined" ? class {} : HTMLElement;
|
|
70
|
+
|
|
71
|
+
export class GdsLens extends ElementBase {
|
|
72
|
+
#ready = null;
|
|
73
|
+
|
|
74
|
+
connectedCallback() {
|
|
75
|
+
if (mounted && mounted !== this) {
|
|
76
|
+
this.#refuse();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// Moved in the DOM rather than recreated: this element still owns its
|
|
80
|
+
// own shadow tree, so there is nothing to redo.
|
|
81
|
+
if (this.#ready) {
|
|
82
|
+
mounted = this;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
mounted = this;
|
|
86
|
+
warnIfNotUtf8();
|
|
87
|
+
this.#ready = this.#mount();
|
|
88
|
+
|
|
89
|
+
// Deliberately after #ready is assigned, and deliberately not awaited
|
|
90
|
+
// inside #mount: load() waits on this.ready, so loading from within
|
|
91
|
+
// the promise that resolves it would wait on itself forever.
|
|
92
|
+
const src = this.getAttribute("src");
|
|
93
|
+
if (src) this.#ready.then(() => this.load(src));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async #mount() {
|
|
97
|
+
// Already running from an earlier element: move it here rather than
|
|
98
|
+
// building a second engine, which is not possible.
|
|
99
|
+
if (engine) {
|
|
100
|
+
engine.adopt(this);
|
|
101
|
+
return engine.viewer;
|
|
102
|
+
}
|
|
103
|
+
setMountTarget(this);
|
|
104
|
+
// Deferred on purpose: this is what pulls in the engine, instantiates
|
|
105
|
+
// the wasm module and creates the GL context.
|
|
106
|
+
engine = await import("./viewer.js");
|
|
107
|
+
return engine.viewer;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#refuse() {
|
|
111
|
+
console.error(
|
|
112
|
+
"[GDS] only one <gds-lens> can be active at a time: the renderer keeps " +
|
|
113
|
+
"its state in module-scope globals, so a second one would fight the first. " +
|
|
114
|
+
"Remove the first from the DOM and this one will take over its engine."
|
|
115
|
+
);
|
|
116
|
+
this.textContent = "Only one <gds-lens> is supported at a time.";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Resolves once the engine has mounted, so callers can await readiness
|
|
120
|
+
// without racing the dynamic import.
|
|
121
|
+
get ready() {
|
|
122
|
+
return this.#ready || Promise.reject(new Error("<gds-lens> is not connected"));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Accepts what a caller is likely to already have: a URL to fetch, or the
|
|
126
|
+
// bytes themselves in either of the two shapes they usually arrive in.
|
|
127
|
+
async load(source, options) {
|
|
128
|
+
const viewer = await this.ready;
|
|
129
|
+
if (typeof source === "string") {
|
|
130
|
+
const response = await fetch(source);
|
|
131
|
+
if (!response.ok) throw new Error(`${source}: HTTP ${response.status}`);
|
|
132
|
+
return viewer.load(new Uint8Array(await response.arrayBuffer()), options);
|
|
133
|
+
}
|
|
134
|
+
return viewer.load(source, options);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async goToPoint(x, y) {
|
|
138
|
+
return (await this.ready).goToPoint(x, y);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async setLyp(name, text) {
|
|
142
|
+
return (await this.ready).setLyp(name, text);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async setMarkers(name, text) {
|
|
146
|
+
return (await this.ready).setMarkers(name, text);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async showError(message) {
|
|
150
|
+
return (await this.ready).showError(message);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Releases the claim without tearing anything down. The element is very
|
|
154
|
+
// often coming straight back -- React and friends recreate the node on
|
|
155
|
+
// re-render -- and discarding the wasm instance and GL context only to
|
|
156
|
+
// rebuild them a tick later would be far worse than holding them. The
|
|
157
|
+
// engine stays up; the next element to connect adopts it.
|
|
158
|
+
disconnectedCallback() {
|
|
159
|
+
if (mounted === this) mounted = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
static get observedAttributes() {
|
|
163
|
+
return ["src"];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
attributeChangedCallback(name, previous, current) {
|
|
167
|
+
if (name === "src" && current && current !== previous && this.#ready) {
|
|
168
|
+
this.load(current);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (typeof customElements !== "undefined" && !customElements.get("gds-lens")) {
|
|
174
|
+
customElements.define("gds-lens", GdsLens);
|
|
175
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The default ViewerHost: the services the viewer needs from whatever is
|
|
2
|
+
// embedding it, implemented for a plain web page.
|
|
3
|
+
//
|
|
4
|
+
// The viewer itself knows nothing about VS Code, or about any other host. It
|
|
5
|
+
// asks for a file, asks for a name, asks to be reloaded, and hands back state
|
|
6
|
+
// to persist. Everything host-specific lives behind this interface, so a
|
|
7
|
+
// second embedder only has to supply these methods rather than fork the
|
|
8
|
+
// viewer.
|
|
9
|
+
//
|
|
10
|
+
// A host sets `window.gdsLensHost` before viewer.js runs to replace this one.
|
|
11
|
+
// Every method here is optional: the viewer hides the control for anything a
|
|
12
|
+
// host leaves out (a host with no pickLyp gets no "Load .lyp" row), so a
|
|
13
|
+
// read-only embed can implement almost none of it.
|
|
14
|
+
//
|
|
15
|
+
// pickLyp() -> Promise<{name, text} | null> null = cancelled
|
|
16
|
+
// unloadLyp() -> void
|
|
17
|
+
// pickMarkers() -> Promise<{name, text} | null>
|
|
18
|
+
// unloadMarkers() -> void
|
|
19
|
+
// loadViews() -> Promise<view[]>
|
|
20
|
+
// saveViews(views) -> void
|
|
21
|
+
// promptViewName(existingNames) -> Promise<string | null>
|
|
22
|
+
// requestReload() -> void
|
|
23
|
+
// setAutoReload(on) -> void
|
|
24
|
+
// onGotoResult({ok, x, y}) -> void
|
|
25
|
+
// connect(viewer) -> void the viewer's own surface, for pushing in
|
|
26
|
+
//
|
|
27
|
+
// `connect` is how a host drives the viewer rather than answering it:
|
|
28
|
+
//
|
|
29
|
+
// viewer.load(bytes, { reload }) viewer.showError(message)
|
|
30
|
+
// viewer.setLyp(name, text) viewer.setMarkers(name, text)
|
|
31
|
+
// viewer.showStale(text) viewer.goToPoint(x, y)
|
|
32
|
+
// viewer.toggleDebug() viewer.element (the mounted element)
|
|
33
|
+
|
|
34
|
+
export function createBrowserHost() {
|
|
35
|
+
// Views are per-layout everywhere else; a plain page has no document
|
|
36
|
+
// identity to key on, so everything in one page shares a bucket. Wrapped
|
|
37
|
+
// because storage throws outright in some privacy modes rather than
|
|
38
|
+
// merely coming back empty.
|
|
39
|
+
const VIEWS_KEY = "gds-lens:named-views";
|
|
40
|
+
const readViews = () => {
|
|
41
|
+
try {
|
|
42
|
+
// Array-checked, not just parse-guarded: the catch only covers a
|
|
43
|
+
// throw, and `null` or `5` parse perfectly well while being
|
|
44
|
+
// nothing the caller can iterate.
|
|
45
|
+
const parsed = JSON.parse(localStorage.getItem(VIEWS_KEY) || "[]");
|
|
46
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// One <input type=file> reused for both pickers: creating it lazily and
|
|
53
|
+
// leaving it detached keeps it out of the layout, and the click has to
|
|
54
|
+
// happen inside the user gesture that opened the picker or the browser
|
|
55
|
+
// discards it.
|
|
56
|
+
const pickText = (accept) => new Promise((resolve) => {
|
|
57
|
+
const input = document.createElement("input");
|
|
58
|
+
input.type = "file";
|
|
59
|
+
input.accept = accept;
|
|
60
|
+
input.addEventListener("change", () => {
|
|
61
|
+
const file = input.files && input.files[0];
|
|
62
|
+
if (!file) return resolve(null);
|
|
63
|
+
file.text().then(
|
|
64
|
+
(text) => resolve({ name: file.name, text }),
|
|
65
|
+
() => resolve(null)
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
// No "cancel" event fires reliably across browsers, so a dismissed
|
|
69
|
+
// dialog simply never resolves. That is fine here: the viewer treats
|
|
70
|
+
// a pick as pending until it answers, and nothing is blocked on it.
|
|
71
|
+
input.click();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
pickLyp: () => pickText(".lyp"),
|
|
76
|
+
pickMarkers: () => pickText(".lyrdb,.txt,.db"),
|
|
77
|
+
loadViews: async () => readViews(),
|
|
78
|
+
saveViews: (views) => {
|
|
79
|
+
try {
|
|
80
|
+
localStorage.setItem(VIEWS_KEY, JSON.stringify(views));
|
|
81
|
+
} catch {
|
|
82
|
+
// Full or blocked storage: the views stay live in this page,
|
|
83
|
+
// they just will not outlive it. Not worth interrupting for.
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
// A plain page has to be told which layout to show. Three ways in,
|
|
87
|
+
// covering the three situations a page is actually in: a URL for an
|
|
88
|
+
// embed, a drop for a scratch look at a local file, and a direct call
|
|
89
|
+
// for a page driving the viewer itself (or a test).
|
|
90
|
+
connect: (viewer) => {
|
|
91
|
+
// Published for a plain page (and the test suite) to drive the
|
|
92
|
+
// viewer from the console or a script tag. This is the default
|
|
93
|
+
// host's doing, not the element's: an embedder that replaces
|
|
94
|
+
// gds-lens-host.js gets no such global unless it sets one.
|
|
95
|
+
window.gdsLens = viewer;
|
|
96
|
+
|
|
97
|
+
const loadBytes = (bytes) => viewer.load(bytes, { reload: false });
|
|
98
|
+
|
|
99
|
+
const src = new URLSearchParams(location.search).get("src");
|
|
100
|
+
if (src) {
|
|
101
|
+
fetch(src)
|
|
102
|
+
.then((response) => {
|
|
103
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
104
|
+
return response.arrayBuffer();
|
|
105
|
+
})
|
|
106
|
+
.then((buffer) => loadBytes(new Uint8Array(buffer)))
|
|
107
|
+
.catch((err) => viewer.showError(`Could not fetch ${src}: ${err.message || err}`));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Bound to the viewer's own element, not to window. On window,
|
|
111
|
+
// the preventDefault below fires for every drag anywhere in the
|
|
112
|
+
// embedding page, which silently disables the host application's
|
|
113
|
+
// own drop targets -- a component has no business doing that. The
|
|
114
|
+
// fallback keeps a plain page working if the surface predates
|
|
115
|
+
// `element`.
|
|
116
|
+
// Both handlers are required: without preventDefault on dragover
|
|
117
|
+
// the browser navigates away to the dropped file instead of
|
|
118
|
+
// handing it over.
|
|
119
|
+
const bindDrop = (target) => {
|
|
120
|
+
if (!target) return;
|
|
121
|
+
target.addEventListener("dragover", (event) => event.preventDefault());
|
|
122
|
+
target.addEventListener("drop", (event) => {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
const file = event.dataTransfer && event.dataTransfer.files[0];
|
|
125
|
+
if (!file) return;
|
|
126
|
+
file.arrayBuffer().then(
|
|
127
|
+
(buffer) => loadBytes(new Uint8Array(buffer)),
|
|
128
|
+
(err) => viewer.showError(`Could not read ${file.name}: ${err.message || err}`)
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
bindDrop(viewer.element || document.querySelector("gds-lens"));
|
|
134
|
+
// The viewer can move to a different element (a framework
|
|
135
|
+
// re-render recreates the node), which leaves the listeners above
|
|
136
|
+
// on something detached. Rebind on the new one.
|
|
137
|
+
if (typeof viewer.onAdopt === "function") viewer.onAdopt(bindDrop);
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
promptViewName: async (existingNames) => {
|
|
141
|
+
const name = window.prompt(
|
|
142
|
+
existingNames.length
|
|
143
|
+
? `Name this view (reusing a name replaces it):\n${existingNames.join(", ")}`
|
|
144
|
+
: "Name this view:"
|
|
145
|
+
);
|
|
146
|
+
return name && name.trim() ? name.trim() : null;
|
|
147
|
+
}
|
|
148
|
+
// No requestReload / setAutoReload / onGotoResult: a plain page has no
|
|
149
|
+
// file on disk to watch and no command palette to answer. The viewer
|
|
150
|
+
// hides the stale-file banner when requestReload is missing.
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (typeof window !== "undefined" && !window.gdsLensHost) {
|
|
155
|
+
window.gdsLensHost = createBrowserHost();
|
|
156
|
+
}
|