clay-server 4.8.2 → 4.9.0-beta.1
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/LICENSES/README.md +13 -0
- package/LICENSES/open-iconic.txt +21 -0
- package/LICENSES/plantuml-salt.txt +674 -0
- package/lib/assets/salt-assets.json +15 -0
- package/lib/assets/salt-colors.json +156 -0
- package/lib/assets/salt-font-10.bin.gz +0 -0
- package/lib/assets/salt-font-13.bin.gz +0 -0
- package/lib/assets/salt-font-14.bin.gz +0 -0
- package/lib/assets/salt-font-16.bin.gz +0 -0
- package/lib/assets/salt-font-18.bin.gz +0 -0
- package/lib/assets/salt-font-20.bin.gz +0 -0
- package/lib/assets/salt-font-metrics.bin.gz +0 -0
- package/lib/assets/salt-font-metrics.json +101 -0
- package/lib/assets/salt-font-monospace-10.bin.gz +0 -0
- package/lib/assets/salt-font-monospace-13.bin.gz +0 -0
- package/lib/assets/salt-font-monospace-14.bin.gz +0 -0
- package/lib/assets/salt-font-monospace-16.bin.gz +0 -0
- package/lib/assets/salt-font-monospace-18.bin.gz +0 -0
- package/lib/assets/salt-font-monospace-20.bin.gz +0 -0
- package/lib/assets/salt-font-monospace.bin.gz +0 -0
- package/lib/assets/salt-icons.json +671 -0
- package/lib/bundled-skills/clay-sketch/SKILL.md +198 -0
- package/lib/plantuml-renderer.js +43 -0
- package/lib/project-file-http.js +2 -0
- package/lib/project-plantuml.js +48 -0
- package/lib/project-session-document.js +17 -1
- package/lib/project-wireframe-http.js +88 -0
- package/lib/public/app.js +6 -0
- package/lib/public/css/filebrowser.css +5 -0
- package/lib/public/css/wireframe.css +95 -0
- package/lib/public/modules/app-messages.js +8 -1
- package/lib/public/modules/filebrowser.js +29 -3
- package/lib/public/modules/markdown.js +3 -1
- package/lib/public/modules/plantuml-preview.js +76 -0
- package/lib/public/modules/split-view.js +14 -0
- package/lib/public/modules/wireframe-chat.js +52 -0
- package/lib/public/modules/wireframe-render.js +69 -0
- package/lib/public/modules/wireframe-style.js +6 -0
- package/lib/public/modules/wireframe-viewport.js +70 -0
- package/lib/public/modules/wireframe-workbench.js +116 -0
- package/lib/public/style.css +1 -0
- package/lib/salt-chrome.js +36 -0
- package/lib/salt-hand.js +37 -0
- package/lib/salt-images.js +87 -0
- package/lib/salt-layout.js +60 -0
- package/lib/salt-options.js +23 -0
- package/lib/salt-overlays.js +45 -0
- package/lib/salt-parser.js +153 -0
- package/lib/salt-screen.js +111 -0
- package/lib/salt-svg-shapes.js +80 -0
- package/lib/salt-svg.js +164 -0
- package/lib/salt-text.js +112 -0
- package/lib/salt-tree.js +45 -0
- package/lib/sdk-bridge.js +2 -1
- package/lib/session-document-mcp-server.js +9 -0
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ import { enterMarkdownSlides, exitMarkdownSlides, handleMarkdownSlideKey, syncMa
|
|
|
12
12
|
import { initFileViewerTabs, openFileViewerTab, previewFileViewerTab, updateFileViewerTab, closeFileViewerTab, focusedFileViewerTab, focusedFileViewerTabData, clearFileViewerTabs } from './filebrowser-tabs.js';
|
|
13
13
|
import { initFileBrowserContextMenu, downloadProjectFile } from './filebrowser-context-menu.js';
|
|
14
14
|
import { registerClayFileLinkOpener } from './clay-file-links.js';
|
|
15
|
+
import { isPlantUmlFile, renderPlantUmlPreview, cancelPlantUmlPreview } from './plantuml-preview.js';
|
|
15
16
|
|
|
16
17
|
var ctx;
|
|
17
18
|
var showDropHint = function () {};
|
|
@@ -195,8 +196,12 @@ export function initFileBrowser(_ctx) {
|
|
|
195
196
|
|
|
196
197
|
// Markdown render toggle
|
|
197
198
|
document.getElementById("file-viewer-render").addEventListener("click", function () {
|
|
198
|
-
if (
|
|
199
|
+
if (currentContent == null || (!currentIsMarkdown && !currentIsSvg && !store.get("fileViewerIsPlantUml"))) return;
|
|
199
200
|
isRendered = !isRendered;
|
|
201
|
+
if (store.get("fileViewerIsPlantUml")) {
|
|
202
|
+
renderPlantUmlBody();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
200
205
|
if (currentIsSvg) {
|
|
201
206
|
renderSvgBody();
|
|
202
207
|
return;
|
|
@@ -485,6 +490,7 @@ export function closeFileViewer() {
|
|
|
485
490
|
}
|
|
486
491
|
|
|
487
492
|
function teardownFileViewer() {
|
|
493
|
+
cancelPlantUmlPreview();
|
|
488
494
|
if (currentFilePath && isFollowingMarkdown(currentFilePath)) cancelMarkdownFollow();
|
|
489
495
|
sendUnwatch();
|
|
490
496
|
inlineDiffActive = false;
|
|
@@ -522,6 +528,8 @@ export function resetFileBrowser() {
|
|
|
522
528
|
isRendered = false;
|
|
523
529
|
currentIsMarkdown = false;
|
|
524
530
|
currentIsSvg = false;
|
|
531
|
+
store.set({ fileViewerIsPlantUml: false });
|
|
532
|
+
cancelPlantUmlPreview();
|
|
525
533
|
historyVisible = false;
|
|
526
534
|
currentHistoryEntries = [];
|
|
527
535
|
pendingNavigate = null;
|
|
@@ -1157,6 +1165,8 @@ function showFileContent(msg, expectedRevision) {
|
|
|
1157
1165
|
currentFilePath = msg.path;
|
|
1158
1166
|
currentIsMarkdown = false;
|
|
1159
1167
|
currentIsSvg = false;
|
|
1168
|
+
store.set({ fileViewerIsPlantUml: false });
|
|
1169
|
+
cancelPlantUmlPreview();
|
|
1160
1170
|
if (!keepRenderState) isRendered = false;
|
|
1161
1171
|
var requestedExt = msg.path.split(".").pop().toLowerCase();
|
|
1162
1172
|
if (pendingRenderedOpen && (requestedExt === "md" || requestedExt === "mdx")) {
|
|
@@ -1183,13 +1193,14 @@ function showFileContent(msg, expectedRevision) {
|
|
|
1183
1193
|
lightweightPreview = (msg.size || 0) > FILE_RICH_PREVIEW_MAX_BYTES;
|
|
1184
1194
|
currentIsMarkdown = !lightweightPreview && (ext === "md" || ext === "mdx");
|
|
1185
1195
|
currentIsSvg = !lightweightPreview && ext === "svg";
|
|
1196
|
+
store.set({ fileViewerIsPlantUml: !lightweightPreview && isPlantUmlFile(msg.path) });
|
|
1186
1197
|
if (lightweightPreview) isRendered = false;
|
|
1187
1198
|
if (pendingRenderedOpen && currentIsMarkdown) isRendered = true;
|
|
1188
1199
|
|
|
1189
|
-
if (currentIsMarkdown || currentIsSvg) {
|
|
1200
|
+
if (currentIsMarkdown || currentIsSvg || store.get("fileViewerIsPlantUml")) {
|
|
1190
1201
|
renderBtn.classList.remove("hidden");
|
|
1191
1202
|
renderBtn.title = currentIsSvg ? "Show SVG source" : "Render markdown";
|
|
1192
|
-
copyBtn.title = currentIsSvg ? "Copy SVG source" : "Copy Markdown source";
|
|
1203
|
+
copyBtn.title = store.get("fileViewerIsPlantUml") ? "Copy PlantUML source" : currentIsSvg ? "Copy SVG source" : "Copy Markdown source";
|
|
1193
1204
|
} else {
|
|
1194
1205
|
copyBtn.title = "Copy contents";
|
|
1195
1206
|
}
|
|
@@ -1203,6 +1214,9 @@ function showFileContent(msg, expectedRevision) {
|
|
|
1203
1214
|
? (previousContent == null ? "" : previousContent)
|
|
1204
1215
|
: null;
|
|
1205
1216
|
renderBody(transitionFrom, refreshSlideIndex);
|
|
1217
|
+
} else if (store.get("fileViewerIsPlantUml")) {
|
|
1218
|
+
if (!keepRenderState) isRendered = !store.get("pendingFileNavigation");
|
|
1219
|
+
renderPlantUmlBody();
|
|
1206
1220
|
} else if (currentIsSvg) {
|
|
1207
1221
|
if (!keepRenderState) isRendered = true;
|
|
1208
1222
|
renderSvgBody();
|
|
@@ -1269,6 +1283,16 @@ function showFileContent(msg, expectedRevision) {
|
|
|
1269
1283
|
}
|
|
1270
1284
|
}
|
|
1271
1285
|
|
|
1286
|
+
function renderPlantUmlBody() {
|
|
1287
|
+
var bodyEl = document.getElementById("file-viewer-body");
|
|
1288
|
+
var renderBtn = document.getElementById("file-viewer-render");
|
|
1289
|
+
cancelPlantUmlPreview();
|
|
1290
|
+
renderBtn.classList.toggle("active", isRendered);
|
|
1291
|
+
renderBtn.title = isRendered ? "Show source" : "Show diagram preview";
|
|
1292
|
+
if (isRendered) renderPlantUmlPreview(bodyEl, currentContent, currentFilePath);
|
|
1293
|
+
else renderCodeWithLineNumbers(bodyEl, currentContent, "plaintext");
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1272
1296
|
function renderSvgBody() {
|
|
1273
1297
|
var bodyEl = document.getElementById("file-viewer-body");
|
|
1274
1298
|
var renderBtn = document.getElementById("file-viewer-render");
|
|
@@ -1626,6 +1650,8 @@ function rerenderFileContent() {
|
|
|
1626
1650
|
|
|
1627
1651
|
if (currentIsMarkdown) {
|
|
1628
1652
|
renderBody();
|
|
1653
|
+
} else if (store.get("fileViewerIsPlantUml")) {
|
|
1654
|
+
renderPlantUmlBody();
|
|
1629
1655
|
} else if (currentIsSvg) {
|
|
1630
1656
|
renderSvgBody();
|
|
1631
1657
|
} else {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { renderWireframeBlocks } from './wireframe-chat.js';
|
|
1
2
|
import { copyToClipboard, escapeHtml } from './utils.js';
|
|
2
3
|
import { refreshIcons } from './icons.js';
|
|
3
4
|
import { getMermaidThemeVars } from './theme.js';
|
|
@@ -128,7 +129,7 @@ export function highlightCodeBlocks(el) {
|
|
|
128
129
|
// messages container, so unhighlighted blocks (selector :not(.hljs))
|
|
129
130
|
// are picked up in a single pass.
|
|
130
131
|
if (store.get('replayingHistory')) return;
|
|
131
|
-
el.querySelectorAll("pre code:not(.hljs):not(.language-mermaid)").forEach(function (block) {
|
|
132
|
+
el.querySelectorAll("pre code:not(.hljs):not(.language-mermaid):not(.language-plantuml):not(.language-puml):not(.language-salt):not(.language-clay-sketch)").forEach(function (block) {
|
|
132
133
|
var cls = Array.from(block.classList).find(function (c) { return c.startsWith("language-"); });
|
|
133
134
|
if (cls) {
|
|
134
135
|
var lang = cls.replace("language-", "");
|
|
@@ -167,6 +168,7 @@ export function renderMermaidBlocks(el) {
|
|
|
167
168
|
// Same rationale as highlightCodeBlocks: defer mermaid renders until
|
|
168
169
|
// history_done so they don't cause cascading layout shifts during replay.
|
|
169
170
|
if (store.get('replayingHistory')) return;
|
|
171
|
+
renderWireframeBlocks(el);
|
|
170
172
|
var blocks = el.querySelectorAll("pre code.language-mermaid");
|
|
171
173
|
blocks.forEach(function (codeEl) {
|
|
172
174
|
var pre = codeEl.parentElement;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { store } from './store.js';
|
|
2
|
+
import { mountWireframeViewport } from './wireframe-viewport.js';
|
|
3
|
+
import { sketchBrand, sketchCredit } from './wireframe-style.js';
|
|
4
|
+
|
|
5
|
+
export function isPlantUmlFile(path) {
|
|
6
|
+
return /\.(puml|plantuml|pu|uml|salt)$/i.test(String(path || ""));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function cancelPlantUmlPreview() {
|
|
10
|
+
var job = store.get('plantUmlPreviewJob');
|
|
11
|
+
if (!job) return;
|
|
12
|
+
clearTimeout(job.timer);
|
|
13
|
+
job.observer.disconnect();
|
|
14
|
+
job.controller.abort();
|
|
15
|
+
store.set({ plantUmlPreviewJob: null });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderPlantUmlPreview(body, source, path) {
|
|
19
|
+
cancelPlantUmlPreview();
|
|
20
|
+
body.innerHTML = "";
|
|
21
|
+
var preview = document.createElement("div");
|
|
22
|
+
preview.className = "file-viewer-plantuml-preview";
|
|
23
|
+
var status = document.createElement("div");
|
|
24
|
+
status.className = "file-tree-loading";
|
|
25
|
+
status.setAttribute("role", "status");
|
|
26
|
+
status.textContent = "Rendering diagram…";
|
|
27
|
+
preview.appendChild(status);
|
|
28
|
+
body.appendChild(preview);
|
|
29
|
+
|
|
30
|
+
function showError(message) {
|
|
31
|
+
status.className = "file-tree-error";
|
|
32
|
+
status.textContent = message + " Use Show source to inspect the file.";
|
|
33
|
+
}
|
|
34
|
+
if (!String(source || "").trim()) { showError("This diagram is empty."); return; }
|
|
35
|
+
if (new TextEncoder().encode(source).length > 100000) { showError("Diagram is too large to preview (100 KB maximum)."); return; }
|
|
36
|
+
|
|
37
|
+
var job = { controller: new AbortController(), observer: null, timer: null };
|
|
38
|
+
job.observer = new MutationObserver(function () {
|
|
39
|
+
if (!preview.isConnected && store.get('plantUmlPreviewJob') === job) cancelPlantUmlPreview();
|
|
40
|
+
});
|
|
41
|
+
job.observer.observe(body, { childList: true });
|
|
42
|
+
job.timer = setTimeout(function () {
|
|
43
|
+
if (store.get('plantUmlPreviewJob') !== job) return;
|
|
44
|
+
showError("The diagram preview timed out.");
|
|
45
|
+
cancelPlantUmlPreview();
|
|
46
|
+
}, 20000);
|
|
47
|
+
store.set({ plantUmlPreviewJob: job });
|
|
48
|
+
fetch("api/file/plantuml?path=" + encodeURIComponent(path), { signal: job.controller.signal, cache: "no-store" }).then(function (response) {
|
|
49
|
+
return response.text().then(function (text) {
|
|
50
|
+
if (!response.ok) throw new Error(text || "Unable to render this diagram.");
|
|
51
|
+
return text;
|
|
52
|
+
});
|
|
53
|
+
}).then(function (svg) {
|
|
54
|
+
if (store.get('plantUmlPreviewJob') !== job || !preview.isConnected) return;
|
|
55
|
+
if (svg.length > 8 * 1024 * 1024 || !/<svg[\s>]/i.test(svg)) throw new Error("The renderer returned an invalid diagram.");
|
|
56
|
+
var image = document.createElement("img");
|
|
57
|
+
image.alt = "Diagram preview of " + path;
|
|
58
|
+
image.draggable = false;
|
|
59
|
+
// An image context disables scripts, links and external SVG resources.
|
|
60
|
+
image.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
|
|
61
|
+
image.addEventListener("error", function () { showError("The diagram image could not be displayed."); });
|
|
62
|
+
status.textContent = "";
|
|
63
|
+
var heading = document.createElement('header'); heading.className = 'sketch-tools';
|
|
64
|
+
heading.innerHTML = '<div class="sketch-brand">' + sketchBrand() + '</div>' + sketchCredit();
|
|
65
|
+
preview.appendChild(heading);
|
|
66
|
+
var toolbar = document.createElement('div');
|
|
67
|
+
var viewport = document.createElement('div'); viewport.className = 'wireframe-file-canvas';
|
|
68
|
+
preview.appendChild(toolbar); preview.appendChild(viewport); viewport.appendChild(image);
|
|
69
|
+
mountWireframeViewport(viewport, image, toolbar);
|
|
70
|
+
|
|
71
|
+
}).catch(function (error) {
|
|
72
|
+
if (store.get('plantUmlPreviewJob') === job && error.name !== "AbortError") showError(error.message);
|
|
73
|
+
}).finally(function () {
|
|
74
|
+
if (store.get('plantUmlPreviewJob') === job) cancelPlantUmlPreview();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { openWireframe } from './wireframe-workbench.js';
|
|
1
2
|
// Two-pane iframe shell for the split-view spike.
|
|
2
3
|
|
|
3
4
|
import { store } from './store.js';
|
|
@@ -142,6 +143,19 @@ function handlePaneMessage(event) {
|
|
|
142
143
|
if (event.origin !== window.location.origin) return;
|
|
143
144
|
var msg = event.data;
|
|
144
145
|
if (!msg || !host) return;
|
|
146
|
+
if (msg.type === "clay-pane-present-wireframe") {
|
|
147
|
+
var frames = host.querySelectorAll(".split-pane-frame");
|
|
148
|
+
var split = store.get('splitPanes');
|
|
149
|
+
var trusted = Array.prototype.some.call(frames, function (frame) {
|
|
150
|
+
if (frame.contentWindow !== event.source || frame.dataset.projectSlug !== msg.projectSlug || String(frame.dataset.sessionId) !== String(msg.sessionId) || msg.projectSlug !== store.get('currentSlug')) return false;
|
|
151
|
+
return split && split.panes.some(function (pane) {
|
|
152
|
+
var expected = new URL('/p/' + encodeURIComponent(pane.slug) + '/?pane=1&session=' + encodeURIComponent(pane.sessionId), window.location.href).href;
|
|
153
|
+
return pane.slug === msg.projectSlug && String(pane.sessionId) === String(msg.sessionId) && frame.src === expected;
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
if (trusted && msg.message) openWireframe(msg.message);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
145
159
|
if (msg.type === "clay-pane-present-markdown") {
|
|
146
160
|
var present = msg.message;
|
|
147
161
|
if (present) presentMarkdownEdit(present);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { sketchBrand, sketchCredit } from './wireframe-style.js';
|
|
2
|
+
import { store } from './store.js';
|
|
3
|
+
import { renderWireframe, wireframeSource, wireframeImage } from './wireframe-render.js';
|
|
4
|
+
import { openWireframe } from './wireframe-workbench.js';
|
|
5
|
+
import { mountWireframeViewport } from './wireframe-viewport.js';
|
|
6
|
+
|
|
7
|
+
export function renderWireframeBlocks(root) {
|
|
8
|
+
if (store.get('replayingHistory')) return;
|
|
9
|
+
root.querySelectorAll('pre code.language-clay-sketch, pre code.language-plantuml, pre code.language-puml, pre code.language-salt').forEach(function (code) {
|
|
10
|
+
var pre = code.parentElement;
|
|
11
|
+
if (!pre || pre.dataset.wireframeProcessed) return;
|
|
12
|
+
var source = wireframeSource(code.textContent, code.classList.contains('language-salt') ? 'salt' : 'plantuml');
|
|
13
|
+
// Wait for a complete PlantUML body while assistant text is streaming.
|
|
14
|
+
if (!/^\s*@end(?:salt|uml)\b/im.test(source)) return;
|
|
15
|
+
pre.dataset.wireframeProcessed = 'true';
|
|
16
|
+
var card = document.createElement('section');
|
|
17
|
+
card.className = 'wireframe-card';
|
|
18
|
+
card.setAttribute('aria-label', 'clay-sketch preview');
|
|
19
|
+
var header = document.createElement('header');
|
|
20
|
+
var label = document.createElement('div'); label.className = 'sketch-brand'; label.innerHTML = sketchBrand(); header.appendChild(label);
|
|
21
|
+
var open = document.createElement('button'); open.type = 'button'; open.dataset.openSketch = ''; open.textContent = 'Open sketch';
|
|
22
|
+
open.addEventListener('click', function (event) { event.stopPropagation(); openWireframe({ source: source, title: 'Sketch viewer' }); });
|
|
23
|
+
header.appendChild(open); card.appendChild(header);
|
|
24
|
+
var canvas = document.createElement('div'); canvas.className = 'wireframe-canvas'; card.appendChild(canvas);
|
|
25
|
+
var status = document.createElement('p'); status.setAttribute('role', 'status'); status.textContent = 'Rendering sketch…'; canvas.appendChild(status);
|
|
26
|
+
var details = document.createElement('details');
|
|
27
|
+
var summary = document.createElement('summary'); summary.textContent = 'Sketch source'; details.appendChild(summary);
|
|
28
|
+
pre.replaceWith(card); details.appendChild(pre);
|
|
29
|
+
var footer = document.createElement('div'); footer.className = 'sketch-source-footer'; footer.appendChild(details);
|
|
30
|
+
var credit = document.createElement('div'); credit.innerHTML = sketchCredit(); footer.appendChild(credit); card.appendChild(footer);
|
|
31
|
+
function render(current) {
|
|
32
|
+
canvas.replaceChildren(status); status.textContent = 'Rendering sketch…';
|
|
33
|
+
return renderWireframe(current, canvas).then(function (svg) {
|
|
34
|
+
if (!card.isConnected) return;
|
|
35
|
+
var image = wireframeImage(svg, 'clay-sketch preview');
|
|
36
|
+
canvas.replaceChildren(image);
|
|
37
|
+
mountWireframeViewport(canvas, image);
|
|
38
|
+
}).catch(function (error) {
|
|
39
|
+
if (!card.isConnected) return;
|
|
40
|
+
status.textContent = error.name === 'AbortError' ? 'Rendering was interrupted. Try again.' : error.message;
|
|
41
|
+
var retry = document.createElement('button'); retry.type = 'button'; retry.textContent = 'Retry';
|
|
42
|
+
retry.onclick = function () {
|
|
43
|
+
retry.remove();
|
|
44
|
+
render(current).catch(function () {});
|
|
45
|
+
};
|
|
46
|
+
canvas.appendChild(retry);
|
|
47
|
+
throw error;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
render(source).catch(function () {});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { store } from './store.js';
|
|
2
|
+
|
|
3
|
+
export function wireframeSource(source, language) {
|
|
4
|
+
source = String(source || '');
|
|
5
|
+
if (language === 'salt' && !/@start\w+/i.test(source)) return '@startsalt\n' + source + '\n@endsalt';
|
|
6
|
+
return source;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function wireframeImage(svg, title) {
|
|
10
|
+
if (svg.length > 8 * 1024 * 1024 || !/<svg[\s>]/i.test(svg)) throw new Error('The renderer returned an invalid diagram.');
|
|
11
|
+
var image = document.createElement('img');
|
|
12
|
+
image.alt = title || 'Wireframe';
|
|
13
|
+
image.draggable = false;
|
|
14
|
+
// SVG is displayed in an inert image context, never inserted as markup.
|
|
15
|
+
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
|
16
|
+
return image;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pump() {
|
|
20
|
+
if (store.get('wireframeRenderActive')) return;
|
|
21
|
+
var queue = (store.get('wireframeRenderQueue') || []).slice();
|
|
22
|
+
var job = queue.shift();
|
|
23
|
+
if (!job) return;
|
|
24
|
+
store.set({ wireframeRenderQueue: queue });
|
|
25
|
+
if (!job.target.isConnected) { job.reject(new Error('Preview closed.')); pump(); return; }
|
|
26
|
+
var controller = new AbortController();
|
|
27
|
+
store.set({ wireframeRenderActive: controller });
|
|
28
|
+
var timer = setTimeout(function () { controller.abort(); }, 20000);
|
|
29
|
+
fetch('api/wireframe/render', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
body: JSON.stringify({ source: job.source }), signal: controller.signal, cache: 'no-store' }).then(function (response) {
|
|
31
|
+
return response.text().then(function (text) {
|
|
32
|
+
if (!response.ok) throw new Error(text || 'Unable to render this wireframe.');
|
|
33
|
+
return text;
|
|
34
|
+
});
|
|
35
|
+
}).then(function (svg) {
|
|
36
|
+
if (store.get('wireframeRenderActive') !== controller) throw new Error('Conversation changed.');
|
|
37
|
+
wireframeImage(svg);
|
|
38
|
+
if (svg.length < 500000) {
|
|
39
|
+
var cache = (store.get('wireframeRenderCache') || []).filter(function (entry) { return entry.source !== job.source; });
|
|
40
|
+
cache.push({ source: job.source, svg: svg });
|
|
41
|
+
store.set({ wireframeRenderCache: cache.slice(-12) });
|
|
42
|
+
}
|
|
43
|
+
job.resolve(svg);
|
|
44
|
+
}).catch(function (error) { job.reject(error); }).finally(function () {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
if (store.get('wireframeRenderActive') === controller) store.set({ wireframeRenderActive: null });
|
|
47
|
+
pump();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renderWireframe(source, target) {
|
|
52
|
+
if (!source.trim() || new TextEncoder().encode(source).length > 100000) return Promise.reject(new Error('Wireframes must contain between 1 byte and 100 KB of source.'));
|
|
53
|
+
var cache = store.get('wireframeRenderCache') || [];
|
|
54
|
+
var hit = cache.find(function (entry) { return entry.source === source; });
|
|
55
|
+
if (hit) return Promise.resolve(hit.svg);
|
|
56
|
+
return new Promise(function (resolve, reject) {
|
|
57
|
+
var queue = (store.get('wireframeRenderQueue') || []).slice();
|
|
58
|
+
queue.push({ source: source, target: target, resolve: resolve, reject: reject });
|
|
59
|
+
store.set({ wireframeRenderQueue: queue });
|
|
60
|
+
pump();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
store.subscribe(function (state, previous) {
|
|
65
|
+
if (state.currentSlug === previous.currentSlug && state.activeSessionId === previous.activeSessionId && state.myUserId === previous.myUserId) return;
|
|
66
|
+
if (previous.wireframeRenderActive) previous.wireframeRenderActive.abort();
|
|
67
|
+
(previous.wireframeRenderQueue || []).forEach(function (job) { job.reject(new Error('Conversation changed.')); });
|
|
68
|
+
store.set({ wireframeRenderActive: null, wireframeRenderQueue: [], wireframeRenderCache: [] });
|
|
69
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function sketchBrand() {
|
|
2
|
+
return '<span class="sketch-mark" aria-hidden="true">✎</span><span class="sketch-wordmark">clay<span>sketch</span></span>';
|
|
3
|
+
}
|
|
4
|
+
export function sketchCredit() {
|
|
5
|
+
return '<a class="sketch-credit" href="https://plantuml.com/salt" target="_blank" rel="noopener noreferrer">Built with PlantUML Salt <span aria-hidden="true">↗</span></a>';
|
|
6
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { store } from './store.js';
|
|
2
|
+
|
|
3
|
+
function entries() { return store.get('wireframeViewports') || new Map(); }
|
|
4
|
+
function apply(entry) {
|
|
5
|
+
var image = entry.image;
|
|
6
|
+
if (!image.naturalWidth || !entry.viewport.isConnected || entries().get(entry.viewport) !== entry) return;
|
|
7
|
+
var style = getComputedStyle(entry.viewport);
|
|
8
|
+
var available = entry.viewport.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
|
9
|
+
if (available <= 0) return;
|
|
10
|
+
var fitted = Math.min(1, available / image.naturalWidth);
|
|
11
|
+
if (entry.viewport.classList.contains('wireframe-workbench-canvas')) {
|
|
12
|
+
var height = entry.viewport.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
|
13
|
+
if (height > 0) fitted = Math.min(fitted, height / image.naturalHeight);
|
|
14
|
+
}
|
|
15
|
+
// Default to the whole design at natural scale or smaller; zoom is explicit.
|
|
16
|
+
var scale = entry.mode === 'manual' ? entry.zoom : fitted;
|
|
17
|
+
image.style.width = image.naturalWidth * scale + 'px';
|
|
18
|
+
image.style.maxWidth = 'none';
|
|
19
|
+
image.style.height = 'auto';
|
|
20
|
+
image.style.margin = image.naturalWidth * scale > available ? '0' : '0 auto';
|
|
21
|
+
entry.scale = scale;
|
|
22
|
+
if (!entry.toolbar) return;
|
|
23
|
+
entry.toolbar.querySelector('[data-zoom-value]').textContent = Math.round(scale * 100) + '%';
|
|
24
|
+
entry.toolbar.querySelector('[data-zoom-fit]').setAttribute('aria-pressed', String(entry.mode === 'fit'));
|
|
25
|
+
entry.toolbar.querySelector('[data-zoom-out]').disabled = scale <= 0.25;
|
|
26
|
+
entry.toolbar.querySelector('[data-zoom-in]').disabled = scale >= 4;
|
|
27
|
+
}
|
|
28
|
+
function cleanup() {
|
|
29
|
+
var next = new Map(entries());
|
|
30
|
+
next.forEach(function (entry, viewport) {
|
|
31
|
+
if (viewport.isConnected && entry.image.isConnected) return;
|
|
32
|
+
entry.resize.disconnect(); next.delete(viewport);
|
|
33
|
+
});
|
|
34
|
+
if (next.size !== entries().size) store.set({ wireframeViewports: next });
|
|
35
|
+
if (!next.size) {
|
|
36
|
+
var observer = store.get('wireframeViewportCleanup');
|
|
37
|
+
if (observer) observer.disconnect();
|
|
38
|
+
store.set({ wireframeViewportCleanup: null });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function mountWireframeViewport(viewport, image, toolbar) {
|
|
42
|
+
var previous = entries().get(viewport);
|
|
43
|
+
if (previous) previous.resize.disconnect();
|
|
44
|
+
var entry = { viewport: viewport, image: image, toolbar: toolbar, mode: previous ? previous.mode : 'auto', zoom: previous ? previous.zoom : 1, scale: 1 };
|
|
45
|
+
entry.resize = new ResizeObserver(function () { apply(entry); });
|
|
46
|
+
var next = new Map(entries()); next.set(viewport, entry);
|
|
47
|
+
store.set({ wireframeViewports: next });
|
|
48
|
+
if (toolbar) {
|
|
49
|
+
toolbar.classList.add('wireframe-zoom');
|
|
50
|
+
toolbar.setAttribute('role', 'group'); toolbar.setAttribute('aria-label', 'Sketch zoom');
|
|
51
|
+
toolbar.innerHTML = '<button type="button" data-zoom-out aria-label="Zoom out" title="Zoom out">−</button><button type="button" data-zoom-value aria-label="Reset zoom to 100%" title="Original size">100%</button><button type="button" data-zoom-in aria-label="Zoom in" title="Zoom in">+</button><button type="button" data-zoom-fit aria-pressed="true" title="Fit sketch to available width">Fit</button>';
|
|
52
|
+
function change(mode, zoom) {
|
|
53
|
+
entry.mode = mode; entry.zoom = zoom;
|
|
54
|
+
viewport.scrollLeft = 0; viewport.scrollTop = 0;
|
|
55
|
+
apply(entry);
|
|
56
|
+
}
|
|
57
|
+
toolbar.querySelector('[data-zoom-out]').onclick = function () { change('manual', Math.max(0.25, entry.scale / 1.25)); };
|
|
58
|
+
toolbar.querySelector('[data-zoom-in]').onclick = function () { change('manual', Math.min(4, Math.max(0.25, entry.scale * 1.25))); };
|
|
59
|
+
toolbar.querySelector('[data-zoom-value]').onclick = function () { change('manual', 1); };
|
|
60
|
+
toolbar.querySelector('[data-zoom-fit]').onclick = function () { change('fit', 1); };
|
|
61
|
+
}
|
|
62
|
+
image.addEventListener('load', function () { apply(entry); }, { once: true });
|
|
63
|
+
entry.resize.observe(viewport);
|
|
64
|
+
if (!store.get('wireframeViewportCleanup')) {
|
|
65
|
+
var observer = new MutationObserver(cleanup);
|
|
66
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
67
|
+
store.set({ wireframeViewportCleanup: observer });
|
|
68
|
+
}
|
|
69
|
+
apply(entry);
|
|
70
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { sketchBrand, sketchCredit } from './wireframe-style.js';
|
|
2
|
+
import { store } from './store.js';
|
|
3
|
+
import { claimRightWorkbench, registerRightWorkbench, releaseRightWorkbench } from './right-workbench.js';
|
|
4
|
+
import { renderWireframe, wireframeSource, wireframeImage } from './wireframe-render.js';
|
|
5
|
+
import { refreshIcons } from './icons.js';
|
|
6
|
+
import { mountWireframeViewport } from './wireframe-viewport.js';
|
|
7
|
+
|
|
8
|
+
function panel() { return document.getElementById('wireframe-panel'); }
|
|
9
|
+
export function closeWireframe() {
|
|
10
|
+
releaseRightWorkbench('wireframe');
|
|
11
|
+
if (panel()) panel().remove();
|
|
12
|
+
store.set({ wireframeDraft: null });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function download() {
|
|
16
|
+
var draft = store.get('wireframeDraft');
|
|
17
|
+
if (!draft) return;
|
|
18
|
+
var link = document.createElement('a');
|
|
19
|
+
link.href = URL.createObjectURL(new Blob([draft.source], { type: 'text/plain;charset=utf-8' }));
|
|
20
|
+
link.download = (draft.id || 'wireframe') + '.puml';
|
|
21
|
+
link.click();
|
|
22
|
+
setTimeout(function () { URL.revokeObjectURL(link.href); }, 1000);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function save(event) {
|
|
26
|
+
event.preventDefault();
|
|
27
|
+
var el = panel();
|
|
28
|
+
var draft = store.get('wireframeDraft');
|
|
29
|
+
var form = el.querySelector('form');
|
|
30
|
+
var button = form.querySelector('[type="submit"]');
|
|
31
|
+
var status = el.querySelector('.wireframe-save-status');
|
|
32
|
+
var requested = form.elements.path.value.trim();
|
|
33
|
+
button.disabled = true; status.textContent = 'Saving…';
|
|
34
|
+
fetch('api/wireframe/save', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({ path: requested, source: draft.source }) }).then(function (response) {
|
|
36
|
+
if (!response.ok) return response.text().then(function (text) { throw new Error(text); });
|
|
37
|
+
return response.json();
|
|
38
|
+
}).then(function (result) {
|
|
39
|
+
if (store.get('wireframeDraft') !== draft) return;
|
|
40
|
+
status.textContent = 'Saved to ' + result.path;
|
|
41
|
+
form.hidden = true;
|
|
42
|
+
}).catch(function (error) {
|
|
43
|
+
if (store.get('wireframeDraft') === draft) status.textContent = error.message || 'Unable to save the wireframe.';
|
|
44
|
+
}).finally(function () { button.disabled = false; });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createPanel() {
|
|
48
|
+
var host = document.getElementById('main-panels');
|
|
49
|
+
if (!host) return null;
|
|
50
|
+
var el = document.createElement('section'); el.id = 'wireframe-panel';
|
|
51
|
+
el.setAttribute('aria-label', 'clay-sketch viewer');
|
|
52
|
+
el.innerHTML = '<header class="wireframe-topbar"><div class="sketch-brand">' + sketchBrand() + '</div><strong class="sketch-title">Sketch viewer</strong><div class="wireframe-window-actions"><button type="button" data-wide title="Widen panel" aria-label="Widen panel" aria-pressed="false"><i data-lucide="chevrons-left-right"></i></button><button type="button" data-full title="Toggle fullscreen" aria-label="Toggle fullscreen" aria-pressed="false"><i data-lucide="maximize-2"></i></button><button type="button" data-close title="Close clay-sketch" aria-label="Close clay-sketch"><i data-lucide="x"></i></button></div></header><div class="wireframe-toolbar"><div class="wireframe-zoom"></div><button type="button" data-save>Save to project</button><button type="button" data-download title="Download sketch source" aria-label="Download sketch source"><i data-lucide="download"></i></button></div><form hidden class="wireframe-save-form"><label>Project file path<input name="path" aria-label="Project file path" required pattern=".*\\.puml" placeholder="wireframe.puml"></label><button type="submit">Save</button><button type="button" data-cancel>Cancel</button></form><p class="wireframe-save-status" role="status"></p><div class="wireframe-workbench-canvas"></div><footer class="sketch-footer">' + sketchCredit() + '<span>Revise through conversation</span></footer>';
|
|
53
|
+
host.appendChild(el);
|
|
54
|
+
el.querySelector('[data-close]').onclick = closeWireframe;
|
|
55
|
+
el.querySelector('[data-download]').onclick = download;
|
|
56
|
+
el.querySelector('[data-save]').onclick = function () {
|
|
57
|
+
var form = el.querySelector('form'); form.hidden = false;
|
|
58
|
+
var draft = store.get('wireframeDraft');
|
|
59
|
+
form.elements.path.value = (draft.id || 'wireframe') + '.puml';
|
|
60
|
+
form.elements.path.focus();
|
|
61
|
+
};
|
|
62
|
+
el.querySelector('[data-cancel]').onclick = function () { el.querySelector('form').hidden = true; el.querySelector('[data-save]').focus(); };
|
|
63
|
+
el.querySelector('form').onsubmit = save;
|
|
64
|
+
el.addEventListener('keydown', function (event) {
|
|
65
|
+
if (event.key !== 'Escape') return;
|
|
66
|
+
event.stopPropagation();
|
|
67
|
+
if (!el.querySelector('form').hidden) el.querySelector('[data-cancel]').click();
|
|
68
|
+
else closeWireframe();
|
|
69
|
+
});
|
|
70
|
+
['wide', 'full'].forEach(function (action) {
|
|
71
|
+
el.querySelector('[data-' + action + ']').onclick = function () {
|
|
72
|
+
var active = el.classList.toggle(action === 'wide' ? 'wireframe-wide' : 'panel-fullscreen');
|
|
73
|
+
this.setAttribute('aria-pressed', String(active));
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
refreshIcons();
|
|
77
|
+
return el;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function openWireframe(message) {
|
|
81
|
+
if (!message || typeof message.source !== 'string' || new TextEncoder().encode(message.source).length > 100000) return;
|
|
82
|
+
if (store.get('paneMode') && window.parent !== window) {
|
|
83
|
+
window.parent.postMessage({ type: 'clay-pane-present-wireframe', message: message, projectSlug: store.get('currentSlug'), sessionId: store.get('activeSessionId') }, window.location.origin);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
claimRightWorkbench('wireframe');
|
|
87
|
+
var el = panel() || createPanel();
|
|
88
|
+
if (!el) return;
|
|
89
|
+
var draft = { source: wireframeSource(message.source, message.language), title: String(message.title || 'clay-sketch').slice(0, 100),
|
|
90
|
+
id: /^[a-zA-Z0-9_-]{1,80}$/.test(message.id || '') ? message.id : 'wireframe' };
|
|
91
|
+
store.set({ wireframeDraft: draft });
|
|
92
|
+
el.querySelector('header strong').textContent = draft.title;
|
|
93
|
+
el.querySelector('.wireframe-save-status').textContent = '';
|
|
94
|
+
el.querySelector('form').hidden = true;
|
|
95
|
+
draw(draft, el).catch(function () {});
|
|
96
|
+
}
|
|
97
|
+
function draw(draft, el) {
|
|
98
|
+
var canvas = el.querySelector('.wireframe-workbench-canvas');
|
|
99
|
+
var active = store.get('wireframeDraft');
|
|
100
|
+
var status = document.createElement('p'); status.setAttribute('role', 'status'); status.textContent = 'Rendering sketch…';
|
|
101
|
+
canvas.replaceChildren(status);
|
|
102
|
+
return renderWireframe(draft.source, canvas).then(function (svg) {
|
|
103
|
+
if (store.get('wireframeDraft') !== active || !canvas.isConnected) throw new DOMException('Sketch changed.', 'AbortError');
|
|
104
|
+
var image = wireframeImage(svg, draft.title);
|
|
105
|
+
canvas.replaceChildren(image);
|
|
106
|
+
mountWireframeViewport(canvas, image, el.querySelector('.wireframe-zoom'));
|
|
107
|
+
}).catch(function (error) {
|
|
108
|
+
if (store.get('wireframeDraft') !== active) throw error;
|
|
109
|
+
status.textContent = error.name === 'AbortError' ? 'Rendering was interrupted. Reopen the wireframe to retry.' : error.message;
|
|
110
|
+
throw error;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
registerRightWorkbench('wireframe', closeWireframe);
|
|
114
|
+
store.subscribe(function (state, previous) {
|
|
115
|
+
if (state.currentSlug !== previous.currentSlug || state.activeSessionId !== previous.activeSessionId || state.myUserId !== previous.myUserId) closeWireframe();
|
|
116
|
+
});
|
package/lib/public/style.css
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Diagram decoration geometry adapted from PlantUML 1.2026.8 (GPL-3.0-or-later).
|
|
2
|
+
var text = require('./salt-text');
|
|
3
|
+
function decorate(root, draw, shapes) {
|
|
4
|
+
var options = root.options || {};
|
|
5
|
+
var block = { width: root.width, height: root.height, draw: draw };
|
|
6
|
+
function add(name, top) {
|
|
7
|
+
if (!options[name]) return;
|
|
8
|
+
var small = name === 'header' || name === 'footer';
|
|
9
|
+
var padding = name === 'title' || name === 'legend' ? 5 : 0;
|
|
10
|
+
var margin = name === 'legend' ? 12 : name === 'title' ? 5 : name === 'caption' ? 1 : 0;
|
|
11
|
+
var labels = options.labels[name].map(function (parsed) {
|
|
12
|
+
parsed.runs.forEach(function (run) { run.size = small ? 10 : 14; run.bold = name === 'title' || run.bold; run.color = small ? '#888' : run.color; });
|
|
13
|
+
return text.measureLabel(parsed);
|
|
14
|
+
});
|
|
15
|
+
var textWidth = Math.max.apply(null, labels.map(function (label) { return label.width; }));
|
|
16
|
+
var textHeight = labels.reduce(function (sum, label) { return sum + label.height; }, 0);
|
|
17
|
+
var width = textWidth + 2 * (padding + margin) + 1;
|
|
18
|
+
var height = textHeight + 2 * (padding + margin) + 1;
|
|
19
|
+
var previous = block;
|
|
20
|
+
block = { width: Math.max(previous.width, width), height: previous.height + height, draw: function (x, y) {
|
|
21
|
+
var bx = x + (name === 'header' ? blockWidth - width : (blockWidth - width) / 2);
|
|
22
|
+
var by = y + (top ? 0 : previous.height);
|
|
23
|
+
previous.draw(x + (blockWidth - previous.width) / 2, y + (top ? height : 0));
|
|
24
|
+
if (name === 'legend') shapes.rect(bx + margin, by + margin, textWidth + 2 * padding, textHeight + 2 * padding, '#DDD', '#000', 7.5);
|
|
25
|
+
var yy = by + padding + margin;
|
|
26
|
+
labels.forEach(function (label) {
|
|
27
|
+
var offset = name === 'legend' ? 0 : name === 'header' ? textWidth - label.width : (textWidth - label.width) / 2;
|
|
28
|
+
shapes.label(label, bx + margin + padding + offset, yy); yy += label.height;
|
|
29
|
+
});
|
|
30
|
+
} };
|
|
31
|
+
var blockWidth = block.width;
|
|
32
|
+
}
|
|
33
|
+
add('legend', false); add('title', true); add('caption', false); add('header', true); add('footer', false);
|
|
34
|
+
return block;
|
|
35
|
+
}
|
|
36
|
+
module.exports = { decorate: decorate };
|
package/lib/salt-hand.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Adapted from PlantUML HandJiggle/URectangleHand by Adrian Vogt, GPL-3.0-or-later.
|
|
2
|
+
// See LICENSES/plantuml-salt.txt. Each translated reference graphic resets its seed.
|
|
3
|
+
function jiggle(x, y, variation) {
|
|
4
|
+
var seed = (424242n ^ 25214903917n) & ((1n << 48n) - 1n);
|
|
5
|
+
function bits(n) { seed = (seed * 25214903917n + 11n) & ((1n << 48n) - 1n); return Number(seed >> BigInt(48 - n)); }
|
|
6
|
+
function random() { return (bits(26) * 134217728 + bits(27)) / 9007199254740992; }
|
|
7
|
+
var points = [[x, y]];
|
|
8
|
+
function lineTo(xx, yy) {
|
|
9
|
+
var dx = Math.abs(xx - x); var dy = Math.abs(yy - y); var distance = Math.hypot(dx, dy);
|
|
10
|
+
if (distance < 0.001) return;
|
|
11
|
+
var segments = Math.round(distance / 10); var amount = variation;
|
|
12
|
+
if (segments < 5) { segments = 5; amount /= 3; }
|
|
13
|
+
for (var i = 0; i < segments; i++) {
|
|
14
|
+
var offset = (random() - 0.5) * amount;
|
|
15
|
+
points.push([x + (xx - x) / segments * i - offset * dy / distance, y + (yy - y) / segments * i - offset * dx / distance]);
|
|
16
|
+
}
|
|
17
|
+
points.push([xx, yy]); x = xx; y = yy;
|
|
18
|
+
}
|
|
19
|
+
function arc(a, b, cx, cy, rx, ry) {
|
|
20
|
+
[(a + b) / 2, b].forEach(function (angle) { lineTo(cx + Math.cos(angle) * rx, cy + Math.sin(angle) * ry); });
|
|
21
|
+
}
|
|
22
|
+
return { points: points, lineTo: lineTo, arc: arc };
|
|
23
|
+
}
|
|
24
|
+
function line(dx, dy) { var j = jiggle(0, 0, 2); j.lineTo(dx, dy); return j.points; }
|
|
25
|
+
function rect(w, h, radius) {
|
|
26
|
+
var rx = Math.min(radius || 0, w / 2); var ry = Math.min(radius || 0, h / 2);
|
|
27
|
+
var j = jiggle(rx, 0, 1.5);
|
|
28
|
+
if (!radius) { j.lineTo(w, 0); j.lineTo(w, h); j.lineTo(0, h); j.lineTo(0, 0); }
|
|
29
|
+
else {
|
|
30
|
+
j.lineTo(w - rx, 0); j.arc(-Math.PI / 2, 0, w - rx, ry, rx, ry);
|
|
31
|
+
j.lineTo(w, h - ry); j.arc(0, Math.PI / 2, w - rx, h - ry, rx, ry);
|
|
32
|
+
j.lineTo(rx, h); j.arc(Math.PI / 2, Math.PI, rx, h - ry, rx, ry);
|
|
33
|
+
j.lineTo(0, ry); j.arc(Math.PI, Math.PI * 1.5, rx, ry, rx, ry);
|
|
34
|
+
}
|
|
35
|
+
return j.points;
|
|
36
|
+
}
|
|
37
|
+
module.exports = { line: line, rect: rect };
|