@liminis/diagrams 0.1.0 → 0.1.2
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/LICENSE +0 -13
- package/README.md +22 -0
- package/dist/bin/render-c4.d.ts +30 -0
- package/dist/bin/render-c4.js +174 -0
- package/dist/core/edge-clipping.js +3 -2
- package/dist/core/layout.js +52 -3
- package/dist/core/precision.d.ts +5 -0
- package/dist/core/precision.js +30 -0
- package/dist/react/C4InteractiveRenderer.js +8 -1
- package/dist/react/hooks/useC4DiagramDrag.d.ts +17 -3
- package/dist/react/hooks/useC4DiagramDrag.js +43 -8
- package/dist/react/renderer.js +20 -5
- package/package.json +9 -4
package/LICENSE
CHANGED
|
@@ -19,16 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
19
19
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
20
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
21
|
SOFTWARE.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
This package vendors a modified copy of `mdast-util-wiki-link` (MIT,
|
|
26
|
-
Copyright (c) 2020 Mark Hudnall). Its license and a description of the
|
|
27
|
-
modifications ship alongside it, at
|
|
28
|
-
`dist/markdown/vendor/mdast-util-wiki-link/`.
|
|
29
|
-
|
|
30
|
-
This package derives from the `webview-ui` package of SlashMD
|
|
31
|
-
(https://github.com/wolfdavo/SlashMD) by David Wolfenden, which that project's
|
|
32
|
-
README declares to be MIT-licensed. SlashMD carries no LICENSE file and no
|
|
33
|
-
copyright notice, so none is reproduced here; this notice records the
|
|
34
|
-
derivation and the stated license in their absence.
|
package/README.md
CHANGED
|
@@ -7,6 +7,15 @@ editing.
|
|
|
7
7
|
Extracted from [`@liminis/editor`](https://github.com/verveguy/liminis-editor), where it
|
|
8
8
|
renders ` ```c4 ` fenced code blocks. Nothing here is bound to that editor.
|
|
9
9
|
|
|
10
|
+
## Demo
|
|
11
|
+
|
|
12
|
+
**[https://v3rv.com/liminis-diagrams/](https://v3rv.com/liminis-diagrams/)** — edit
|
|
13
|
+
C4-PlantUML source and see it re-render live, drag nodes to reposition them, toggle dark
|
|
14
|
+
mode, and switch between a few preset diagrams. The demo keeps dragged positions in
|
|
15
|
+
memory only, for as long as the tab is open — this package has no persistence of its
|
|
16
|
+
own (see [Recipe 3](docs/recipes.md#recipe-3-position-persistence--the-hosts-choice)),
|
|
17
|
+
and neither does this demo.
|
|
18
|
+
|
|
10
19
|
## Install
|
|
11
20
|
|
|
12
21
|
```bash
|
|
@@ -80,6 +89,19 @@ positions for. Persisting them is entirely your call — see
|
|
|
80
89
|
example (including how `@liminis/editor` does it) and why this package itself never
|
|
81
90
|
writes them anywhere.
|
|
82
91
|
|
|
92
|
+
## Render on the command line
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx --package=@liminis/diagrams -- render-c4 diagram.puml
|
|
96
|
+
# diagram.puml -> diagram.svg
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Useful for pre-rendering diagrams so a plain `` is enough for
|
|
100
|
+
GitHub (or any markdown renderer) to show them — see
|
|
101
|
+
[`docs/github-integration.md`](docs/github-integration.md) for the CI recipe, and
|
|
102
|
+
[`docs/claude-code-integration.md`](docs/claude-code-integration.md) for getting Claude
|
|
103
|
+
to render real diagrams instead of hand-drawing them.
|
|
104
|
+
|
|
83
105
|
## Supported syntax
|
|
84
106
|
|
|
85
107
|
`Person`, `System`, `Container`, `Component` and their `_Ext` / `Db` / `Queue` variants,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `render-c4` — render C4-PlantUML source files to SVG on disk.
|
|
4
|
+
*
|
|
5
|
+
* This is the CLI form of `renderC4DiagramToSVG` (`@liminis/diagrams/server`),
|
|
6
|
+
* meant for pre-rendering diagrams in CI so that a plain `` in
|
|
7
|
+
* a markdown file is enough for GitHub (or any other markdown renderer) to show
|
|
8
|
+
* it — no live rendering service, no image-provider proxy, nothing to host.
|
|
9
|
+
* See docs/github-integration.md for the recipe this exists for.
|
|
10
|
+
*
|
|
11
|
+
* No dependency is added for argument parsing: flags are hand-rolled to match
|
|
12
|
+
* the style of the other scripts in this repo (guard-publish.mjs,
|
|
13
|
+
* verify-package.mjs), and the surface here is small enough not to need one.
|
|
14
|
+
*/
|
|
15
|
+
export interface Options {
|
|
16
|
+
files: string[];
|
|
17
|
+
dark: boolean;
|
|
18
|
+
out?: string;
|
|
19
|
+
outDir?: string;
|
|
20
|
+
check: boolean;
|
|
21
|
+
stdin: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function parseArgs(argv: string[]): Options | null;
|
|
24
|
+
export declare function outputPathFor(inputPath: string, options: Options): string;
|
|
25
|
+
/**
|
|
26
|
+
* Null when `options` is a valid combination; otherwise the message to report
|
|
27
|
+
* (without the `render-c4: ` prefix `main` adds).
|
|
28
|
+
*/
|
|
29
|
+
export declare function validateStdinCombination(options: Options): string | null;
|
|
30
|
+
export declare function renderFiles(options: Options): number;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `render-c4` — render C4-PlantUML source files to SVG on disk.
|
|
4
|
+
*
|
|
5
|
+
* This is the CLI form of `renderC4DiagramToSVG` (`@liminis/diagrams/server`),
|
|
6
|
+
* meant for pre-rendering diagrams in CI so that a plain `` in
|
|
7
|
+
* a markdown file is enough for GitHub (or any other markdown renderer) to show
|
|
8
|
+
* it — no live rendering service, no image-provider proxy, nothing to host.
|
|
9
|
+
* See docs/github-integration.md for the recipe this exists for.
|
|
10
|
+
*
|
|
11
|
+
* No dependency is added for argument parsing: flags are hand-rolled to match
|
|
12
|
+
* the style of the other scripts in this repo (guard-publish.mjs,
|
|
13
|
+
* verify-package.mjs), and the surface here is small enough not to need one.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import { dirname, basename, extname, join } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { renderC4DiagramToSVG } from '../server/render-to-string.js';
|
|
19
|
+
function printUsage() {
|
|
20
|
+
console.log(`Usage: render-c4 [options] <files...>
|
|
21
|
+
|
|
22
|
+
Render C4-PlantUML source files to SVG.
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--dark Render in dark mode
|
|
26
|
+
-o, --out <file> Output path (only valid with exactly one input file)
|
|
27
|
+
--out-dir <dir> Write outputs here, preserving basenames (.svg extension)
|
|
28
|
+
--check Validate only — write nothing, exit non-zero on any error
|
|
29
|
+
--stdin Read source from stdin, write SVG to stdout
|
|
30
|
+
-h, --help Show this help
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
export function parseArgs(argv) {
|
|
34
|
+
const options = { files: [], dark: false, check: false, stdin: false };
|
|
35
|
+
for (let i = 0; i < argv.length; i++) {
|
|
36
|
+
const arg = argv[i];
|
|
37
|
+
switch (arg) {
|
|
38
|
+
case '-h':
|
|
39
|
+
case '--help':
|
|
40
|
+
return null;
|
|
41
|
+
case '--dark':
|
|
42
|
+
options.dark = true;
|
|
43
|
+
break;
|
|
44
|
+
case '--check':
|
|
45
|
+
options.check = true;
|
|
46
|
+
break;
|
|
47
|
+
case '--stdin':
|
|
48
|
+
options.stdin = true;
|
|
49
|
+
break;
|
|
50
|
+
case '-o':
|
|
51
|
+
case '--out':
|
|
52
|
+
options.out = argv[++i];
|
|
53
|
+
break;
|
|
54
|
+
case '--out-dir':
|
|
55
|
+
options.outDir = argv[++i];
|
|
56
|
+
break;
|
|
57
|
+
default:
|
|
58
|
+
options.files.push(arg);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return options;
|
|
62
|
+
}
|
|
63
|
+
export function outputPathFor(inputPath, options) {
|
|
64
|
+
if (options.out)
|
|
65
|
+
return options.out;
|
|
66
|
+
const svgName = `${basename(inputPath, extname(inputPath))}.svg`;
|
|
67
|
+
return options.outDir ? join(options.outDir, svgName) : join(dirname(inputPath), svgName);
|
|
68
|
+
}
|
|
69
|
+
function renderStdin(dark) {
|
|
70
|
+
const chunks = [];
|
|
71
|
+
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
72
|
+
process.stdin.on('end', () => {
|
|
73
|
+
const source = Buffer.concat(chunks).toString('utf-8');
|
|
74
|
+
const { svg, errors } = renderC4DiagramToSVG(source, dark);
|
|
75
|
+
if (errors.length > 0) {
|
|
76
|
+
for (const error of errors) {
|
|
77
|
+
console.error(`<stdin>:${error.line}:${error.column}: ${error.message}`);
|
|
78
|
+
}
|
|
79
|
+
process.exitCode = 2;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
process.stdout.write(svg);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Null when `options` is a valid combination; otherwise the message to report
|
|
87
|
+
* (without the `render-c4: ` prefix `main` adds).
|
|
88
|
+
*/
|
|
89
|
+
export function validateStdinCombination(options) {
|
|
90
|
+
if (options.stdin && (options.check || options.out !== undefined || options.outDir !== undefined || options.files.length > 0)) {
|
|
91
|
+
return '--stdin cannot be combined with --check, -o/--out, --out-dir, or file arguments';
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
export function renderFiles(options) {
|
|
96
|
+
if (options.out && options.files.length > 1) {
|
|
97
|
+
console.error('render-c4: -o/--out only applies with a single input file');
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
let failures = 0;
|
|
101
|
+
for (const inputPath of options.files) {
|
|
102
|
+
let source;
|
|
103
|
+
try {
|
|
104
|
+
source = readFileSync(inputPath, 'utf-8');
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
failures++;
|
|
108
|
+
console.error(`${inputPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const { svg, errors } = renderC4DiagramToSVG(source, options.dark);
|
|
112
|
+
if (errors.length > 0) {
|
|
113
|
+
failures++;
|
|
114
|
+
for (const error of errors) {
|
|
115
|
+
console.error(`${inputPath}:${error.line}:${error.column}: ${error.message}`);
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (options.check)
|
|
120
|
+
continue;
|
|
121
|
+
const outPath = outputPathFor(inputPath, options);
|
|
122
|
+
try {
|
|
123
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
124
|
+
writeFileSync(outPath, svg);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
failures++;
|
|
128
|
+
console.error(`${inputPath}: failed to write ${outPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
console.log(`${inputPath} -> ${outPath}`);
|
|
132
|
+
}
|
|
133
|
+
return failures > 0 ? 2 : 0;
|
|
134
|
+
}
|
|
135
|
+
function main() {
|
|
136
|
+
const rawArgs = process.argv.slice(2);
|
|
137
|
+
const options = parseArgs(rawArgs);
|
|
138
|
+
if (!options) {
|
|
139
|
+
printUsage();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const stdinConflict = validateStdinCombination(options);
|
|
143
|
+
if (stdinConflict) {
|
|
144
|
+
console.error(`render-c4: ${stdinConflict}`);
|
|
145
|
+
process.exitCode = 1;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (options.files.length === 0 && !options.stdin) {
|
|
149
|
+
if (rawArgs.length === 0) {
|
|
150
|
+
printUsage();
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Flags were given (e.g. `--check` over a glob that matched nothing) but no
|
|
155
|
+
// files resolved — "nothing to do" is success, not a usage error, so a CI
|
|
156
|
+
// step like `render-c4 --check $(git ls-files '*.puml')` doesn't fail a repo
|
|
157
|
+
// that has no diagrams yet.
|
|
158
|
+
console.log('render-c4: no input files');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (options.stdin) {
|
|
162
|
+
renderStdin(options.dark);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
process.exitCode = renderFiles(options);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Only run when executed directly (`node render-c4.js`), not when imported —
|
|
169
|
+
// e.g. by the test file below, which exercises `parseArgs`/`outputPathFor` in
|
|
170
|
+
// isolation without wanting a real CLI invocation as a side effect of import.
|
|
171
|
+
const isMain = process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1];
|
|
172
|
+
if (isMain) {
|
|
173
|
+
main();
|
|
174
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Computes where edge polylines intersect label bounding boxes and splits
|
|
5
5
|
* them into visible segments, creating clean gaps around label text.
|
|
6
6
|
*/
|
|
7
|
+
import { svgNumber } from './precision.js';
|
|
7
8
|
// =============================================================================
|
|
8
9
|
// CONSTANTS
|
|
9
10
|
// =============================================================================
|
|
@@ -127,12 +128,12 @@ export function buildClippedEdgePaths(points, labelCenter, labelHalfW, labelHalf
|
|
|
127
128
|
}
|
|
128
129
|
const paths = visibleSegments
|
|
129
130
|
.filter((seg) => seg.length >= 2)
|
|
130
|
-
.map((seg) => seg.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '));
|
|
131
|
+
.map((seg) => seg.map((p, i) => `${i === 0 ? 'M' : 'L'} ${svgNumber(p.x)} ${svgNumber(p.y)}`).join(' '));
|
|
131
132
|
// Fallback: if clipping consumed the entire edge, draw the original path
|
|
132
133
|
// rather than leaving a floating arrowhead with no line
|
|
133
134
|
if (paths.length === 0 && points.length >= 2) {
|
|
134
135
|
return [
|
|
135
|
-
points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '),
|
|
136
|
+
points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${svgNumber(p.x)} ${svgNumber(p.y)}`).join(' '),
|
|
136
137
|
];
|
|
137
138
|
}
|
|
138
139
|
return paths;
|
package/dist/core/layout.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* with proper boundary group padding.
|
|
7
7
|
*/
|
|
8
8
|
import dagre from '@dagrejs/dagre';
|
|
9
|
+
import { svgNumber } from './precision.js';
|
|
9
10
|
// =============================================================================
|
|
10
11
|
// CONSTANTS
|
|
11
12
|
// =============================================================================
|
|
@@ -800,9 +801,16 @@ export function layoutC4Diagram(diagram, options, manualPositions) {
|
|
|
800
801
|
...DEFAULT_OPTIONS,
|
|
801
802
|
...options,
|
|
802
803
|
};
|
|
803
|
-
// Use manual layout if positions are provided
|
|
804
|
+
// Use manual layout if positions are provided. Rounded here rather than
|
|
805
|
+
// inside that function so the two layout paths cannot diverge on it: this is
|
|
806
|
+
// the branch the drag renderer takes, and the one `renderC4DiagramToSVG`
|
|
807
|
+
// takes when given `manualPositions`, so leaving it unrounded would have left
|
|
808
|
+
// the platform drift in place for exactly the diagrams a user had arranged
|
|
809
|
+
// by hand.
|
|
804
810
|
if (manualPositions && Object.keys(manualPositions).length > 0) {
|
|
805
|
-
|
|
811
|
+
const manual = layoutWithManualPositions(diagram, mergedOptions, manualPositions);
|
|
812
|
+
roundGeometryInPlace(manual);
|
|
813
|
+
return manual;
|
|
806
814
|
}
|
|
807
815
|
const topLevelElements = getTopLevelElements(diagram.elements);
|
|
808
816
|
// Layout top-level elements, using diagram direction if specified
|
|
@@ -828,7 +836,7 @@ export function layoutC4Diagram(diagram, options, manualPositions) {
|
|
|
828
836
|
// Add margin
|
|
829
837
|
width += BOUNDARY_PADDING;
|
|
830
838
|
height += BOUNDARY_PADDING;
|
|
831
|
-
|
|
839
|
+
const result = {
|
|
832
840
|
nodes: allNodes,
|
|
833
841
|
edges,
|
|
834
842
|
width,
|
|
@@ -836,4 +844,45 @@ export function layoutC4Diagram(diagram, options, manualPositions) {
|
|
|
836
844
|
viewBoxX: 0,
|
|
837
845
|
viewBoxY: 0,
|
|
838
846
|
};
|
|
847
|
+
roundGeometryInPlace(result);
|
|
848
|
+
return result;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Round every coordinate in a finished layout, in place.
|
|
852
|
+
*
|
|
853
|
+
* Done once here rather than at each of the ~80 places the renderer writes a
|
|
854
|
+
* coordinate into an attribute: the layout *is* the geometry, so producing it
|
|
855
|
+
* to a fixed precision means every consumer — the React renderer, the headless
|
|
856
|
+
* SVG serialiser, anything a host builds — inherits the same numbers without
|
|
857
|
+
* having to remember to round.
|
|
858
|
+
*
|
|
859
|
+
* In place rather than by copying, because a node appears both in the flat
|
|
860
|
+
* `nodes` list and in its parent's `children`, and rebuilding those would break
|
|
861
|
+
* the identity between them. `svgNumber` is idempotent, so visiting a node
|
|
862
|
+
* twice is harmless.
|
|
863
|
+
*
|
|
864
|
+
* See ./precision.ts for why this exists at all — the short version is that
|
|
865
|
+
* `atan2` is not bit-identical across platforms, and rendered SVGs get
|
|
866
|
+
* committed and diffed.
|
|
867
|
+
*/
|
|
868
|
+
function roundGeometryInPlace(result) {
|
|
869
|
+
// `result.nodes` comes from flattenLayoutNodes, so it already contains every
|
|
870
|
+
// descendant — a node reached through `children` is the same object, and one
|
|
871
|
+
// pass over the flat list covers the tree.
|
|
872
|
+
for (const node of result.nodes) {
|
|
873
|
+
node.x = svgNumber(node.x);
|
|
874
|
+
node.y = svgNumber(node.y);
|
|
875
|
+
node.width = svgNumber(node.width);
|
|
876
|
+
node.height = svgNumber(node.height);
|
|
877
|
+
}
|
|
878
|
+
for (const edge of result.edges) {
|
|
879
|
+
edge.points = edge.points.map((point) => ({
|
|
880
|
+
x: svgNumber(point.x),
|
|
881
|
+
y: svgNumber(point.y),
|
|
882
|
+
}));
|
|
883
|
+
}
|
|
884
|
+
result.width = svgNumber(result.width);
|
|
885
|
+
result.height = svgNumber(result.height);
|
|
886
|
+
result.viewBoxX = svgNumber(result.viewBoxX);
|
|
887
|
+
result.viewBoxY = svgNumber(result.viewBoxY);
|
|
839
888
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numeric precision at the boundary where geometry becomes SVG.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Decimal places kept when a computed number reaches the SVG.
|
|
6
|
+
*
|
|
7
|
+
* This is not cosmetic. IEEE 754 does not require `sin`, `cos` or `atan2` to be
|
|
8
|
+
* correctly rounded, so a platform's libm may return a result one unit in the
|
|
9
|
+
* last place away from another's. macOS and Linux disagree in exactly that way,
|
|
10
|
+
* and the disagreement reaches the output: an edge-label rotation came out as
|
|
11
|
+
* `-9.005931656396022` on one and `-9.005931656396024` on the other. Identical
|
|
12
|
+
* input, identical library version, different bytes.
|
|
13
|
+
*
|
|
14
|
+
* That matters because rendered SVGs get committed and checked for drift in CI.
|
|
15
|
+
* A check that fails depending on who ran it is worse than no check — it trains
|
|
16
|
+
* people to ignore it. Rounding at the boundary makes the output a function of
|
|
17
|
+
* the input alone.
|
|
18
|
+
*
|
|
19
|
+
* Three places is far below anything visible: at this diagram's scale a
|
|
20
|
+
* thousandth of a unit is a thousandth of a pixel, and a thousandth of a degree
|
|
21
|
+
* moves the end of a 200px label by 0.0035px.
|
|
22
|
+
*/
|
|
23
|
+
const SVG_PRECISION = 3;
|
|
24
|
+
/** Round for emission into SVG, without leaving `1.500` where `1.5` will do. */
|
|
25
|
+
export function svgNumber(value) {
|
|
26
|
+
const factor = 10 ** SVG_PRECISION;
|
|
27
|
+
// `+0` rather than the bare result: -0 serialises as "-0", which differs from
|
|
28
|
+
// "0" bytewise while being the same number.
|
|
29
|
+
return Math.round(value * factor) / factor + 0;
|
|
30
|
+
}
|
|
@@ -281,7 +281,14 @@ function C4InteractiveSvg({ layout, isDarkMode, isEditMode, draggedNodeId, svgRe
|
|
|
281
281
|
cursor: isEditMode ? (draggedNodeId ? 'grabbing' : 'default') : 'default',
|
|
282
282
|
}, children: [_jsx(C4RendererContent, { layout: layout, isDarkMode: isDarkMode, legendPositionOverride: legendPositionOverride }), isEditMode && (_jsx("g", { className: "interactive-layer", children: hitAreas.map((area) => (_jsx("rect", { "data-node-id": area.id, x: area.x, y: area.y, width: area.width, height: area.height, fill: "transparent", stroke: "transparent", style: {
|
|
283
283
|
cursor: draggedNodeId === area.id ? 'grabbing' : 'grab',
|
|
284
|
-
|
|
284
|
+
// Without this the browser claims the gesture for panning and
|
|
285
|
+
// zooming before any pointermove reaches us, so a touch drag
|
|
286
|
+
// scrolls the page instead of moving the node. `none` is
|
|
287
|
+
// deliberate rather than `pan-y`: the hit area is a drag
|
|
288
|
+
// target, and a partial allowance still lets the browser
|
|
289
|
+
// steal the gesture mid-drag and fire pointercancel.
|
|
290
|
+
touchAction: 'none',
|
|
291
|
+
}, onPointerDown: (e) => onNodeMouseDown(area.id, area.x, area.y, e) }, area.id))) })), isEditMode && (_jsx("g", { className: "drag-handles-layer", children: hitAreas.map((area) => (_jsx(DragHandle, { x: area.x + area.width - 20, y: area.y + 4, color: handleColor, isDragging: draggedNodeId === area.id }, `handle-${area.id}`))) }))] }) }));
|
|
285
292
|
}
|
|
286
293
|
/**
|
|
287
294
|
* Drag handle indicator (grip icon).
|
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
* Provides drag-and-drop functionality for repositioning C4 diagram elements.
|
|
5
5
|
* Uses window-level listeners during drag so the interaction continues even
|
|
6
6
|
* when the cursor leaves the SVG bounds.
|
|
7
|
+
*
|
|
8
|
+
* Pointer events, not mouse events. This started as mouse-only and did not work
|
|
9
|
+
* on touch devices at all: iPadOS and mobile Safari synthesize `mousedown` and
|
|
10
|
+
* `click` *after* a gesture resolves, but never emit the `mousemove` stream a
|
|
11
|
+
* drag needs, so the node never moved and the page panned instead. Pointer
|
|
12
|
+
* events unify mouse, touch and pen, and are the only way to get one code path
|
|
13
|
+
* for all three. `pointercancel` matters here too — the browser fires it when it
|
|
14
|
+
* decides a touch is a scroll or a system gesture, and without handling it the
|
|
15
|
+
* drag would stay stuck open with no terminating `pointerup`.
|
|
7
16
|
*/
|
|
8
17
|
import { RefObject } from 'react';
|
|
9
18
|
export interface UseC4DiagramDragProps {
|
|
@@ -22,7 +31,12 @@ export interface UseC4DiagramDragReturn {
|
|
|
22
31
|
/** Whether a drag is in progress */
|
|
23
32
|
isDragging: boolean;
|
|
24
33
|
/** Start dragging a node */
|
|
25
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Start dragging a node. Accepts a pointer or mouse event: the parameter is
|
|
36
|
+
* widened rather than switched so existing callers passing a React.MouseEvent
|
|
37
|
+
* still typecheck.
|
|
38
|
+
*/
|
|
39
|
+
startNodeDrag: (nodeId: string, nodeX: number, nodeY: number, e: React.PointerEvent | React.MouseEvent) => void;
|
|
26
40
|
/** Convert screen coordinates to SVG coordinates */
|
|
27
41
|
screenToSvg: (clientX: number, clientY: number) => {
|
|
28
42
|
x: number;
|
|
@@ -32,7 +46,7 @@ export interface UseC4DiagramDragReturn {
|
|
|
32
46
|
/**
|
|
33
47
|
* Hook for managing drag interactions on C4 diagram nodes.
|
|
34
48
|
*
|
|
35
|
-
* During a drag,
|
|
36
|
-
* interaction continues seamlessly when the
|
|
49
|
+
* During a drag, pointermove/pointerup/pointercancel are handled on `window` so
|
|
50
|
+
* the interaction continues seamlessly when the pointer leaves the SVG.
|
|
37
51
|
*/
|
|
38
52
|
export declare function useC4DiagramDrag({ svgRef, onNodeDrag, onNodeDragEnd, enabled, }: UseC4DiagramDragProps): UseC4DiagramDragReturn;
|
|
@@ -4,13 +4,22 @@
|
|
|
4
4
|
* Provides drag-and-drop functionality for repositioning C4 diagram elements.
|
|
5
5
|
* Uses window-level listeners during drag so the interaction continues even
|
|
6
6
|
* when the cursor leaves the SVG bounds.
|
|
7
|
+
*
|
|
8
|
+
* Pointer events, not mouse events. This started as mouse-only and did not work
|
|
9
|
+
* on touch devices at all: iPadOS and mobile Safari synthesize `mousedown` and
|
|
10
|
+
* `click` *after* a gesture resolves, but never emit the `mousemove` stream a
|
|
11
|
+
* drag needs, so the node never moved and the page panned instead. Pointer
|
|
12
|
+
* events unify mouse, touch and pen, and are the only way to get one code path
|
|
13
|
+
* for all three. `pointercancel` matters here too — the browser fires it when it
|
|
14
|
+
* decides a touch is a scroll or a system gesture, and without handling it the
|
|
15
|
+
* drag would stay stuck open with no terminating `pointerup`.
|
|
7
16
|
*/
|
|
8
17
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
9
18
|
/**
|
|
10
19
|
* Hook for managing drag interactions on C4 diagram nodes.
|
|
11
20
|
*
|
|
12
|
-
* During a drag,
|
|
13
|
-
* interaction continues seamlessly when the
|
|
21
|
+
* During a drag, pointermove/pointerup/pointercancel are handled on `window` so
|
|
22
|
+
* the interaction continues seamlessly when the pointer leaves the SVG.
|
|
14
23
|
*/
|
|
15
24
|
export function useC4DiagramDrag({ svgRef, onNodeDrag, onNodeDragEnd, enabled, }) {
|
|
16
25
|
const [draggedNodeId, setDraggedNodeId] = useState(null);
|
|
@@ -18,6 +27,14 @@ export function useC4DiagramDrag({ svgRef, onNodeDrag, onNodeDragEnd, enabled, }
|
|
|
18
27
|
const dragOffsetRef = useRef({ x: 0, y: 0 });
|
|
19
28
|
// Track the last known position for dragEnd callback
|
|
20
29
|
const lastPositionRef = useRef({ x: 0, y: 0 });
|
|
30
|
+
// The pointer that owns the current drag. Mouse events have exactly one
|
|
31
|
+
// stream; pointer events do not — a touch device can deliver concurrent
|
|
32
|
+
// streams for several fingers at once. Without this, a second finger landing
|
|
33
|
+
// on another node overwrites the single-slot drag state, so the first
|
|
34
|
+
// finger's moves start dragging the second node and either finger's release
|
|
35
|
+
// ends the drag. Null when no drag is active; null for a mouse-event caller,
|
|
36
|
+
// which cannot be concurrent anyway.
|
|
37
|
+
const activePointerIdRef = useRef(null);
|
|
21
38
|
// Locked CTM inverse captured at drag start — prevents the accelerating
|
|
22
39
|
// feedback loop where canvas expansion changes the scale mid-drag
|
|
23
40
|
const lockedCtmInverseRef = useRef(null);
|
|
@@ -54,8 +71,14 @@ export function useC4DiagramDrag({ svgRef, onNodeDrag, onNodeDragEnd, enabled, }
|
|
|
54
71
|
const startNodeDrag = useCallback((nodeId, nodeX, nodeY, e) => {
|
|
55
72
|
if (!enabled)
|
|
56
73
|
return;
|
|
74
|
+
// One drag at a time. A pointerdown arriving while a drag is already in
|
|
75
|
+
// progress is a second finger, not a new intent — ignore it rather than
|
|
76
|
+
// letting it hijack the drag already underway.
|
|
77
|
+
if (draggedNodeIdRef.current !== null)
|
|
78
|
+
return;
|
|
57
79
|
e.stopPropagation();
|
|
58
80
|
e.preventDefault();
|
|
81
|
+
activePointerIdRef.current = 'pointerId' in e ? e.pointerId : null;
|
|
59
82
|
// Lock the CTM at drag start
|
|
60
83
|
const svg = svgRef.current;
|
|
61
84
|
const ctm = svg?.getScreenCTM();
|
|
@@ -75,10 +98,13 @@ export function useC4DiagramDrag({ svgRef, onNodeDrag, onNodeDragEnd, enabled, }
|
|
|
75
98
|
useEffect(() => {
|
|
76
99
|
if (!draggedNodeId)
|
|
77
100
|
return;
|
|
78
|
-
const
|
|
101
|
+
const handlePointerMove = (e) => {
|
|
79
102
|
const nodeId = draggedNodeIdRef.current;
|
|
80
103
|
if (!nodeId)
|
|
81
104
|
return;
|
|
105
|
+
// Movement from any other finger is not this drag.
|
|
106
|
+
if (activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current)
|
|
107
|
+
return;
|
|
82
108
|
const svgPoint = screenToSvgRef.current(e.clientX, e.clientY);
|
|
83
109
|
if (!svgPoint)
|
|
84
110
|
return;
|
|
@@ -87,20 +113,29 @@ export function useC4DiagramDrag({ svgRef, onNodeDrag, onNodeDragEnd, enabled, }
|
|
|
87
113
|
lastPositionRef.current = { x: newX, y: newY };
|
|
88
114
|
onNodeDragRef.current?.(nodeId, newX, newY);
|
|
89
115
|
};
|
|
90
|
-
const
|
|
116
|
+
const endDrag = (e) => {
|
|
91
117
|
const nodeId = draggedNodeIdRef.current;
|
|
92
118
|
if (!nodeId)
|
|
93
119
|
return;
|
|
120
|
+
// A second finger lifting must not end the drag the first one owns.
|
|
121
|
+
if (activePointerIdRef.current !== null && e.pointerId !== activePointerIdRef.current)
|
|
122
|
+
return;
|
|
94
123
|
onNodeDragEndRef.current?.(nodeId, lastPositionRef.current.x, lastPositionRef.current.y);
|
|
95
124
|
draggedNodeIdRef.current = null;
|
|
125
|
+
activePointerIdRef.current = null;
|
|
96
126
|
lockedCtmInverseRef.current = null;
|
|
97
127
|
setDraggedNodeId(null);
|
|
98
128
|
};
|
|
99
|
-
window.addEventListener('
|
|
100
|
-
window.addEventListener('
|
|
129
|
+
window.addEventListener('pointermove', handlePointerMove);
|
|
130
|
+
window.addEventListener('pointerup', endDrag);
|
|
131
|
+
// Fired when the browser takes the gesture over (scroll, system swipe) or
|
|
132
|
+
// the pointer is otherwise lost. Without it a touch drag can end with no
|
|
133
|
+
// `pointerup` at all, leaving the node stuck to the finger.
|
|
134
|
+
window.addEventListener('pointercancel', endDrag);
|
|
101
135
|
return () => {
|
|
102
|
-
window.removeEventListener('
|
|
103
|
-
window.removeEventListener('
|
|
136
|
+
window.removeEventListener('pointermove', handlePointerMove);
|
|
137
|
+
window.removeEventListener('pointerup', endDrag);
|
|
138
|
+
window.removeEventListener('pointercancel', endDrag);
|
|
104
139
|
};
|
|
105
140
|
}, [draggedNodeId]);
|
|
106
141
|
return {
|
package/dist/react/renderer.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { buildClippedEdgePaths } from '../core/edge-clipping.js';
|
|
3
|
+
import { svgNumber } from '../core/precision.js';
|
|
3
4
|
// =============================================================================
|
|
4
5
|
// CONSTANTS
|
|
5
6
|
// =============================================================================
|
|
@@ -75,7 +76,11 @@ function calculateArrowheadPoints(startPoint, endPoint) {
|
|
|
75
76
|
const baseX = endPoint.x - ux * ARROW_SIZE;
|
|
76
77
|
const baseY = endPoint.y - uy * ARROW_SIZE;
|
|
77
78
|
const halfW = ARROW_SIZE * 0.5;
|
|
78
|
-
|
|
79
|
+
// Rounded here rather than left raw: these coordinates come out of a sqrt-
|
|
80
|
+
// normalised direction vector, and the SVG they land in gets committed. See
|
|
81
|
+
// ../core/precision.
|
|
82
|
+
const n = svgNumber;
|
|
83
|
+
return `${n(tipX)},${n(tipY)} ${n(baseX + px * halfW)},${n(baseY + py * halfW)} ${n(baseX - px * halfW)},${n(baseY - py * halfW)}`;
|
|
79
84
|
}
|
|
80
85
|
function shortenEdgeEnd(points) {
|
|
81
86
|
if (points.length < 2)
|
|
@@ -400,8 +405,18 @@ function EdgeComponent({ edge, colors }) {
|
|
|
400
405
|
angleDeg += 180;
|
|
401
406
|
if (Math.abs(angleDeg) > 60)
|
|
402
407
|
angleDeg = 0;
|
|
403
|
-
|
|
404
|
-
|
|
408
|
+
// Rounded after the normalisation and the cutoff, so those decisions are made
|
|
409
|
+
// on the same value as before, and once rather than at each of the two places
|
|
410
|
+
// the angle is used — the transform attribute and the label-clipping geometry
|
|
411
|
+
// must agree on one angle. See ../core/precision: atan2 is not bit-identical
|
|
412
|
+
// across platforms.
|
|
413
|
+
angleDeg = svgNumber(angleDeg);
|
|
414
|
+
// Halving a pair of rounded coordinates is exact arithmetic, so this is not a
|
|
415
|
+
// drift risk — but binary representation still turns 249.7775 into
|
|
416
|
+
// 249.77749999999997, which then appears three times in the output. Rounded
|
|
417
|
+
// so the transform and the tspan read as the number they are.
|
|
418
|
+
const labelX = svgNumber(midpoint.x);
|
|
419
|
+
const labelY = svgNumber(midpoint.y);
|
|
405
420
|
const labelTransform = `rotate(${angleDeg}, ${labelX}, ${labelY})`;
|
|
406
421
|
// Compute edge paths with label clipping
|
|
407
422
|
let edgePaths;
|
|
@@ -426,7 +441,7 @@ function EdgeComponent({ edge, colors }) {
|
|
|
426
441
|
}
|
|
427
442
|
else if (edge.isStepNumber || edge.isLegendRef) {
|
|
428
443
|
// Circle/square shapes are opaque — no line clipping needed
|
|
429
|
-
edgePaths = [shortenedPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')];
|
|
444
|
+
edgePaths = [shortenedPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${svgNumber(p.x)} ${svgNumber(p.y)}`).join(' ')];
|
|
430
445
|
}
|
|
431
446
|
else {
|
|
432
447
|
// Clip around text label
|
|
@@ -457,7 +472,7 @@ function EdgeComponent({ edge, colors }) {
|
|
|
457
472
|
}
|
|
458
473
|
}
|
|
459
474
|
else {
|
|
460
|
-
edgePaths = [shortenedPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')];
|
|
475
|
+
edgePaths = [shortenedPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${svgNumber(p.x)} ${svgNumber(p.y)}`).join(' ')];
|
|
461
476
|
}
|
|
462
477
|
// Render label content
|
|
463
478
|
let labelContent = null;
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liminis/diagrams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "C4 architecture diagrams: parse C4-PlantUML, lay out with dagre, render to SVG",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"//repository": "Not cosmetic, and not optional. npm matches this URL against the GitHub Actions OIDC claim when publishing with --provenance; without it the registry rejects the publish outright (E422) after the release tag has already been cut. That is exactly how 0.1.0's first release attempt failed (#6). The `git+https://` scheme and the `.git` suffix are both part of the match
|
|
6
|
+
"//repository": "Not cosmetic, and not optional. npm matches this URL against the GitHub Actions OIDC claim when publishing with --provenance; without it the registry rejects the publish outright (E422) after the release tag has already been cut. That is exactly how 0.1.0's first release attempt failed (#6). The `git+https://` scheme and the `.git` suffix are both part of the match \u2014 the SSH form does not work.",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/verveguy/liminis-diagrams.git"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"//publishing": "Carried over from @liminis/editor deliberately. `prepublishOnly` -> scripts/guard-publish.mjs refuses unless LIMINIS_ALLOW_PUBLISH=1, which is set at step scope in the release workflow and nowhere else, so a release is the only path that publishes. A `private: true` flag would not do this job: `npm publish --dry-run` does NOT report a private package as blocked (npm 10.8.2), so the guard has to be a script.",
|
|
26
26
|
"packageManager": "pnpm@10.33.0",
|
|
27
|
-
"//engines": "A support statement, not a technical floor
|
|
27
|
+
"//engines": "A support statement, not a technical floor \u2014 @dagrejs/dagre declares no engines at all. It says which runtimes this package is maintained against, matching @liminis/editor.",
|
|
28
28
|
"engines": {
|
|
29
29
|
"node": ">=22"
|
|
30
30
|
},
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"//entrypoints": "These point at dist/ here, in the checked-in manifest, and must stay that way. @liminis/editor shipped a broken 0.1.0 by putting them under `publishConfig`: manifest-field overrides there are a pnpm/yarn feature, and npm honours `publishConfig` only for values like access/registry/tag, so it published src/ paths while `files` shipped only dist/. See that package's ADR-078.",
|
|
33
33
|
"main": "./dist/index.js",
|
|
34
34
|
"types": "./dist/index.d.ts",
|
|
35
|
+
"bin": {
|
|
36
|
+
"render-c4": "./dist/bin/render-c4.js"
|
|
37
|
+
},
|
|
35
38
|
"exports": {
|
|
36
39
|
".": {
|
|
37
40
|
"types": "./dist/index.d.ts",
|
|
@@ -48,7 +51,8 @@
|
|
|
48
51
|
"./server": {
|
|
49
52
|
"types": "./dist/server.d.ts",
|
|
50
53
|
"default": "./dist/server.js"
|
|
51
|
-
}
|
|
54
|
+
},
|
|
55
|
+
"./package.json": "./package.json"
|
|
52
56
|
},
|
|
53
57
|
"publishConfig": {
|
|
54
58
|
"access": "public"
|
|
@@ -61,6 +65,7 @@
|
|
|
61
65
|
"clean": "rm -rf dist",
|
|
62
66
|
"prepack": "pnpm run build",
|
|
63
67
|
"prepublishOnly": "node scripts/guard-publish.mjs",
|
|
68
|
+
"verify:package": "node scripts/verify-package.mjs",
|
|
64
69
|
"lint": "eslint src/",
|
|
65
70
|
"lint:fix": "eslint src/ --fix",
|
|
66
71
|
"typecheck": "tsc --noEmit",
|