zerg-ztc 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/dist/App.d.ts.map +1 -1
- package/dist/App.js +16 -0
- package/dist/App.js.map +1 -1
- package/dist/cli.js +13 -8
- package/dist/cli.js.map +1 -1
- package/dist/components/InputArea.d.ts.map +1 -1
- package/dist/components/InputArea.js +9 -1
- package/dist/components/InputArea.js.map +1 -1
- package/dist/ui/views/input_area.js +1 -1
- package/dist/ui/web/frame_render.d.ts +23 -0
- package/dist/ui/web/frame_render.d.ts.map +1 -0
- package/dist/ui/web/frame_render.js +73 -0
- package/dist/ui/web/frame_render.js.map +1 -0
- package/dist/ui/web/index.d.ts +2 -0
- package/dist/ui/web/index.d.ts.map +1 -0
- package/dist/ui/web/index.js +2 -0
- package/dist/ui/web/index.js.map +1 -0
- package/dist/ui/web/render.d.ts +6 -0
- package/dist/ui/web/render.d.ts.map +1 -0
- package/dist/ui/web/render.js +30 -0
- package/dist/ui/web/render.js.map +1 -0
- package/dist/web/mirror_hook.d.ts +4 -0
- package/dist/web/mirror_hook.d.ts.map +1 -0
- package/dist/web/mirror_hook.js +22 -0
- package/dist/web/mirror_hook.js.map +1 -0
- package/dist/web/mirror_server.d.ts +16 -0
- package/dist/web/mirror_server.d.ts.map +1 -0
- package/dist/web/mirror_server.js +177 -0
- package/dist/web/mirror_server.js.map +1 -0
- package/package.json +1 -1
- package/src/App.tsx +18 -0
- package/src/cli.tsx +15 -8
- package/src/components/InputArea.tsx +9 -1
- package/src/ui/views/input_area.ts +1 -1
- package/src/ui/web/frame_render.tsx +148 -0
- package/src/ui/web/index.tsx +1 -0
- package/src/ui/web/render.tsx +41 -0
- package/src/web/index.html +352 -0
- package/src/web/mirror-favicon.svg +4 -0
- package/src/web/mirror.html +641 -0
- package/src/web/mirror_hook.ts +25 -0
- package/src/web/mirror_server.ts +204 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>ZTC Mirror Mode</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
8
|
+
<link rel="shortcut icon" type="image/svg+xml" href="/favicon.svg" />
|
|
9
|
+
<style>
|
|
10
|
+
:root {
|
|
11
|
+
--cell-w: 8px;
|
|
12
|
+
--cell-h: 16px;
|
|
13
|
+
}
|
|
14
|
+
html, body {
|
|
15
|
+
margin: 0;
|
|
16
|
+
padding: 0;
|
|
17
|
+
height: 100%;
|
|
18
|
+
background: #2a2a2a;
|
|
19
|
+
color: #e6e6e6;
|
|
20
|
+
overflow: hidden;
|
|
21
|
+
}
|
|
22
|
+
#stage {
|
|
23
|
+
position: relative;
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
26
|
+
padding: 25px;
|
|
27
|
+
background: #0b0b0b;
|
|
28
|
+
box-sizing: border-box;
|
|
29
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
30
|
+
font-size: 14px;
|
|
31
|
+
line-height: 16px;
|
|
32
|
+
white-space: pre;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
user-select: text;
|
|
35
|
+
-webkit-user-select: text;
|
|
36
|
+
}
|
|
37
|
+
#stage * {
|
|
38
|
+
user-select: text;
|
|
39
|
+
-webkit-user-select: text;
|
|
40
|
+
}
|
|
41
|
+
#overlay {
|
|
42
|
+
position: absolute;
|
|
43
|
+
left: 12px;
|
|
44
|
+
bottom: 12px;
|
|
45
|
+
padding: 6px 8px;
|
|
46
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
background: rgba(0,0,0,0.7);
|
|
49
|
+
color: #e6e6e6;
|
|
50
|
+
border: 1px solid #2a2a2a;
|
|
51
|
+
border-radius: 6px;
|
|
52
|
+
pointer-events: none;
|
|
53
|
+
user-select: none;
|
|
54
|
+
-webkit-user-select: none;
|
|
55
|
+
}
|
|
56
|
+
#toast {
|
|
57
|
+
position: absolute;
|
|
58
|
+
right: 12px;
|
|
59
|
+
top: 12px;
|
|
60
|
+
padding: 6px 10px;
|
|
61
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
62
|
+
font-size: 12px;
|
|
63
|
+
background: rgba(20,20,20,0.9);
|
|
64
|
+
color: #facc15;
|
|
65
|
+
border: 1px solid #2a2a2a;
|
|
66
|
+
border-radius: 6px;
|
|
67
|
+
opacity: 0;
|
|
68
|
+
transform: translateY(-6px);
|
|
69
|
+
transition: opacity 120ms ease, transform 120ms ease;
|
|
70
|
+
pointer-events: none;
|
|
71
|
+
user-select: none;
|
|
72
|
+
-webkit-user-select: none;
|
|
73
|
+
}
|
|
74
|
+
#toast.show {
|
|
75
|
+
opacity: 1;
|
|
76
|
+
transform: translateY(0);
|
|
77
|
+
}
|
|
78
|
+
</style>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<div id="stage"></div>
|
|
82
|
+
<div id="overlay"></div>
|
|
83
|
+
<div id="badge-hover"></div>
|
|
84
|
+
<div id="badge-click"></div>
|
|
85
|
+
<div id="badge-drawer"></div>
|
|
86
|
+
<div id="toast"></div>
|
|
87
|
+
<script>
|
|
88
|
+
const stage = document.getElementById('stage');
|
|
89
|
+
const overlay = document.getElementById('overlay');
|
|
90
|
+
const badgeHover = document.getElementById('badge-hover');
|
|
91
|
+
const badgeClick = document.getElementById('badge-click');
|
|
92
|
+
const badgeDrawer = document.getElementById('badge-drawer');
|
|
93
|
+
const toast = document.getElementById('toast');
|
|
94
|
+
const clientId = Math.random().toString(36).slice(2);
|
|
95
|
+
let debugBorders = false;
|
|
96
|
+
badgeHover.style.display = 'none';
|
|
97
|
+
badgeClick.style.display = 'none';
|
|
98
|
+
badgeDrawer.style.display = 'none';
|
|
99
|
+
badgeClick.addEventListener('click', () => {
|
|
100
|
+
badgeClick.style.display = 'none';
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function measureCell() {
|
|
104
|
+
const probe = document.createElement('span');
|
|
105
|
+
probe.textContent = 'M';
|
|
106
|
+
stage.appendChild(probe);
|
|
107
|
+
const rect = probe.getBoundingClientRect();
|
|
108
|
+
stage.removeChild(probe);
|
|
109
|
+
document.documentElement.style.setProperty('--cell-w', rect.width + 'px');
|
|
110
|
+
document.documentElement.style.setProperty('--cell-h', rect.height + 'px');
|
|
111
|
+
return { w: rect.width, h: rect.height };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let cell = measureCell();
|
|
115
|
+
|
|
116
|
+
function stageBounds() {
|
|
117
|
+
const rect = stage.getBoundingClientRect();
|
|
118
|
+
const style = window.getComputedStyle(stage);
|
|
119
|
+
const padX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
|
120
|
+
const padY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
|
|
121
|
+
return { width: rect.width - padX, height: rect.height - padY };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sendSize() {
|
|
125
|
+
const bounds = stageBounds();
|
|
126
|
+
const cols = Math.max(1, Math.floor(bounds.width / cell.w));
|
|
127
|
+
const rows = Math.max(1, Math.floor(bounds.height / cell.h));
|
|
128
|
+
fetch(`/size?id=${clientId}`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify({ cols, rows })
|
|
132
|
+
}).catch(() => {});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function colorMap(color) {
|
|
136
|
+
const map = {
|
|
137
|
+
gray: '#9a9a9a',
|
|
138
|
+
magenta: '#c084fc',
|
|
139
|
+
yellow: '#facc15',
|
|
140
|
+
green: '#34d399',
|
|
141
|
+
red: '#f87171',
|
|
142
|
+
cyan: '#67e8f9',
|
|
143
|
+
blue: '#60a5fa',
|
|
144
|
+
white: '#e5e7eb'
|
|
145
|
+
};
|
|
146
|
+
return map[color] || color || '#e6e6e6';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clearStage() {
|
|
150
|
+
stage.innerHTML = '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const imagePreviewCache = new Map();
|
|
154
|
+
let hoverBadgePath = null;
|
|
155
|
+
let clickBadgePath = null;
|
|
156
|
+
let drawerBadgePath = null;
|
|
157
|
+
|
|
158
|
+
async function loadImagePreview(path, width = 40) {
|
|
159
|
+
if (imagePreviewCache.has(path)) return;
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(`/file?path=${encodeURIComponent(path)}`);
|
|
162
|
+
if (!res.ok) return;
|
|
163
|
+
const blob = await res.blob();
|
|
164
|
+
const url = URL.createObjectURL(blob);
|
|
165
|
+
const img = new Image();
|
|
166
|
+
img.onload = () => {
|
|
167
|
+
const height = Math.max(1, Math.round((img.height / img.width) * width));
|
|
168
|
+
const canvas = document.createElement('canvas');
|
|
169
|
+
canvas.width = width;
|
|
170
|
+
canvas.height = height * 2;
|
|
171
|
+
const ctx = canvas.getContext('2d');
|
|
172
|
+
if (!ctx) return;
|
|
173
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
174
|
+
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
175
|
+
const lines = [];
|
|
176
|
+
for (let y = 0; y < height; y += 1) {
|
|
177
|
+
let line = '';
|
|
178
|
+
for (let x = 0; x < width; x += 1) {
|
|
179
|
+
const topIdx = (y * 2 * width + x) * 4;
|
|
180
|
+
const botIdx = ((y * 2 + 1) * width + x) * 4;
|
|
181
|
+
const tr = data[topIdx];
|
|
182
|
+
const tg = data[topIdx + 1];
|
|
183
|
+
const tb = data[topIdx + 2];
|
|
184
|
+
const ta = data[topIdx + 3] / 255;
|
|
185
|
+
const br = data[botIdx];
|
|
186
|
+
const bg = data[botIdx + 1];
|
|
187
|
+
const bb = data[botIdx + 2];
|
|
188
|
+
const ba = data[botIdx + 3] / 255;
|
|
189
|
+
const top = `rgb(${Math.round(tr * ta)}, ${Math.round(tg * ta)}, ${Math.round(tb * ta)})`;
|
|
190
|
+
const bottom = `rgb(${Math.round(br * ba)}, ${Math.round(bg * ba)}, ${Math.round(bb * ba)})`;
|
|
191
|
+
line += `<span style="color:${top};background:${bottom};">▀</span>`;
|
|
192
|
+
}
|
|
193
|
+
lines.push(line);
|
|
194
|
+
}
|
|
195
|
+
imagePreviewCache.set(path, lines.join('<br/>'));
|
|
196
|
+
if (hoverBadgePath === path && badgeHover.style.display === 'block') {
|
|
197
|
+
badgeHover.innerHTML = `<div style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \\"Liberation Mono\\", \\"Courier New\\", monospace; line-height:10px; font-size:8px;">${imagePreviewCache.get(path)}</div>`;
|
|
198
|
+
}
|
|
199
|
+
if (clickBadgePath === path && badgeClick.style.display === 'block') {
|
|
200
|
+
badgeClick.innerHTML = `<div style="color:#9ca3af;margin-bottom:6px;">Click to dismiss</div><div style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \\"Liberation Mono\\", \\"Courier New\\", monospace; line-height:10px; font-size:8px;">${imagePreviewCache.get(path)}</div>`;
|
|
201
|
+
}
|
|
202
|
+
if (drawerBadgePath === path && badgeDrawer.style.display === 'block') {
|
|
203
|
+
badgeDrawer.innerHTML = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"><div style="font-weight:600;">Badge detail</div><button id="badge-close" style="background:transparent;border:1px solid #2a2a2a;color:#e6e6e6;padding:4px 8px;border-radius:6px;cursor:pointer;">Close</button></div><div style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \\"Liberation Mono\\", \\"Courier New\\", monospace; line-height:10px; font-size:8px;">${imagePreviewCache.get(path)}</div>`;
|
|
204
|
+
const closeBtn = badgeDrawer.querySelector('#badge-close');
|
|
205
|
+
if (closeBtn) {
|
|
206
|
+
closeBtn.addEventListener('click', () => {
|
|
207
|
+
badgeDrawer.style.display = 'none';
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
URL.revokeObjectURL(url);
|
|
212
|
+
};
|
|
213
|
+
img.src = url;
|
|
214
|
+
} catch {
|
|
215
|
+
// ignore
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function getImagePreview(path) {
|
|
220
|
+
if (!path) return null;
|
|
221
|
+
const cached = imagePreviewCache.get(path);
|
|
222
|
+
if (cached) return cached;
|
|
223
|
+
loadImagePreview(path);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function clearBadgeOverlays() {
|
|
228
|
+
badgeHover.style.display = 'none';
|
|
229
|
+
badgeClick.style.display = 'none';
|
|
230
|
+
badgeDrawer.style.display = 'none';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let lastFrame = null;
|
|
234
|
+
let selectionActive = false;
|
|
235
|
+
|
|
236
|
+
function renderLatest() {
|
|
237
|
+
if (!lastFrame) return;
|
|
238
|
+
clearStage();
|
|
239
|
+
renderFrame(lastFrame, 0, 0);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderFrame(frame, offsetX = 0, offsetY = 0) {
|
|
243
|
+
const x = offsetX + frame.x;
|
|
244
|
+
const y = offsetY + frame.y;
|
|
245
|
+
|
|
246
|
+
if (frame.node.type === 'text') {
|
|
247
|
+
if (debugBorders) {
|
|
248
|
+
const debug = document.createElement('div');
|
|
249
|
+
debug.style.position = 'absolute';
|
|
250
|
+
debug.style.left = `calc(var(--cell-w) * ${x})`;
|
|
251
|
+
debug.style.top = `calc(var(--cell-h) * ${y})`;
|
|
252
|
+
debug.style.width = `calc(var(--cell-w) * ${frame.width})`;
|
|
253
|
+
debug.style.height = `calc(var(--cell-h) * ${frame.height})`;
|
|
254
|
+
debug.style.borderStyle = 'dashed';
|
|
255
|
+
debug.style.borderWidth = '1px';
|
|
256
|
+
debug.style.borderColor = '#3a3a3a';
|
|
257
|
+
debug.style.pointerEvents = 'none';
|
|
258
|
+
stage.appendChild(debug);
|
|
259
|
+
}
|
|
260
|
+
const el = document.createElement('span');
|
|
261
|
+
el.textContent = frame.node.text;
|
|
262
|
+
el.style.position = 'absolute';
|
|
263
|
+
el.style.left = `calc(var(--cell-w) * ${x})`;
|
|
264
|
+
el.style.top = `calc(var(--cell-h) * ${y})`;
|
|
265
|
+
el.style.color = colorMap(frame.node.style && frame.node.style.color);
|
|
266
|
+
if (frame.node.style && frame.node.style.bold) el.style.fontWeight = '600';
|
|
267
|
+
if (frame.node.style && frame.node.style.dimColor) el.style.opacity = '0.6';
|
|
268
|
+
if (frame.node.style && frame.node.style.inverse) {
|
|
269
|
+
el.style.background = '#e6e6e6';
|
|
270
|
+
el.style.color = '#111';
|
|
271
|
+
}
|
|
272
|
+
if (frame.node.style && frame.node.style.badge) {
|
|
273
|
+
const badge = frame.node.style.badge;
|
|
274
|
+
el.style.cursor = 'pointer';
|
|
275
|
+
el.title = badge.preview || '';
|
|
276
|
+
el.addEventListener('mouseenter', (evt) => {
|
|
277
|
+
hoverBadgePath = badge.path || null;
|
|
278
|
+
badgeHover.style.display = 'block';
|
|
279
|
+
badgeHover.style.position = 'fixed';
|
|
280
|
+
badgeHover.style.left = `${Math.min(evt.clientX + 12, window.innerWidth - 280)}px`;
|
|
281
|
+
badgeHover.style.top = `${Math.min(evt.clientY + 12, window.innerHeight - 160)}px`;
|
|
282
|
+
badgeHover.style.maxWidth = '260px';
|
|
283
|
+
badgeHover.style.padding = '8px 10px';
|
|
284
|
+
badgeHover.style.background = 'rgba(20,20,20,0.92)';
|
|
285
|
+
badgeHover.style.border = '1px solid #2a2a2a';
|
|
286
|
+
badgeHover.style.borderRadius = '6px';
|
|
287
|
+
badgeHover.style.color = '#e6e6e6';
|
|
288
|
+
badgeHover.style.fontSize = '12px';
|
|
289
|
+
badgeHover.style.whiteSpace = badge.type === 'image' ? 'pre' : 'pre-wrap';
|
|
290
|
+
badgeHover.style.zIndex = '20';
|
|
291
|
+
if (badge.type === 'image') {
|
|
292
|
+
const html = getImagePreview(badge.path);
|
|
293
|
+
badgeHover.innerHTML = `<div style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \\"Liberation Mono\\", \\"Courier New\\", monospace; line-height:10px; font-size:8px;">${html || 'Loading image...'}</div>`;
|
|
294
|
+
} else {
|
|
295
|
+
badgeHover.textContent = badge.preview;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
el.addEventListener('mouseleave', () => {
|
|
299
|
+
badgeHover.style.display = 'none';
|
|
300
|
+
});
|
|
301
|
+
el.addEventListener('click', () => {
|
|
302
|
+
clickBadgePath = badge.path || null;
|
|
303
|
+
badgeClick.style.display = 'block';
|
|
304
|
+
badgeClick.style.position = 'absolute';
|
|
305
|
+
badgeClick.style.right = '16px';
|
|
306
|
+
badgeClick.style.bottom = '16px';
|
|
307
|
+
badgeClick.style.maxWidth = '360px';
|
|
308
|
+
badgeClick.style.maxHeight = '180px';
|
|
309
|
+
badgeClick.style.overflow = 'auto';
|
|
310
|
+
badgeClick.style.padding = '10px 12px';
|
|
311
|
+
badgeClick.style.background = 'rgba(15,15,15,0.96)';
|
|
312
|
+
badgeClick.style.border = '1px solid #2a2a2a';
|
|
313
|
+
badgeClick.style.borderRadius = '8px';
|
|
314
|
+
badgeClick.style.color = '#e6e6e6';
|
|
315
|
+
badgeClick.style.fontSize = '12px';
|
|
316
|
+
badgeClick.style.whiteSpace = badge.type === 'image' ? 'pre' : 'pre-wrap';
|
|
317
|
+
badgeClick.style.zIndex = '18';
|
|
318
|
+
if (badge.type === 'image') {
|
|
319
|
+
const html = getImagePreview(badge.path);
|
|
320
|
+
badgeClick.innerHTML = `<div style="color:#9ca3af;margin-bottom:6px;">Click to dismiss</div><div style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \\"Liberation Mono\\", \\"Courier New\\", monospace; line-height:10px; font-size:8px;">${html || 'Loading image...'}</div>`;
|
|
321
|
+
} else {
|
|
322
|
+
badgeClick.textContent = badge.full;
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
el.addEventListener('dblclick', () => {
|
|
326
|
+
drawerBadgePath = badge.path || null;
|
|
327
|
+
badgeDrawer.style.display = 'block';
|
|
328
|
+
badgeDrawer.style.position = 'absolute';
|
|
329
|
+
badgeDrawer.style.right = '0';
|
|
330
|
+
badgeDrawer.style.top = '0';
|
|
331
|
+
badgeDrawer.style.width = '360px';
|
|
332
|
+
badgeDrawer.style.height = '100%';
|
|
333
|
+
badgeDrawer.style.background = 'rgba(10,10,10,0.98)';
|
|
334
|
+
badgeDrawer.style.borderLeft = '1px solid #2a2a2a';
|
|
335
|
+
badgeDrawer.style.padding = '16px';
|
|
336
|
+
badgeDrawer.style.color = '#e6e6e6';
|
|
337
|
+
badgeDrawer.style.fontSize = '12px';
|
|
338
|
+
badgeDrawer.style.whiteSpace = badge.type === 'image' ? 'pre' : 'pre-wrap';
|
|
339
|
+
badgeDrawer.style.zIndex = '19';
|
|
340
|
+
if (badge.type === 'image') {
|
|
341
|
+
const html = getImagePreview(badge.path);
|
|
342
|
+
badgeDrawer.innerHTML = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"><div style="font-weight:600;">Badge detail</div><button id="badge-close" style="background:transparent;border:1px solid #2a2a2a;color:#e6e6e6;padding:4px 8px;border-radius:6px;cursor:pointer;">Close</button></div><div style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \\"Liberation Mono\\", \\"Courier New\\", monospace; line-height:10px; font-size:8px;">${html || 'Loading image...'}</div>`;
|
|
343
|
+
} else {
|
|
344
|
+
badgeDrawer.innerHTML = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"><div style="font-weight:600;">Badge detail</div><button id="badge-close" style="background:transparent;border:1px solid #2a2a2a;color:#e6e6e6;padding:4px 8px;border-radius:6px;cursor:pointer;">Close</button></div><div>${badge.full}</div>`;
|
|
345
|
+
}
|
|
346
|
+
const closeBtn = badgeDrawer.querySelector('#badge-close');
|
|
347
|
+
if (closeBtn) {
|
|
348
|
+
closeBtn.addEventListener('click', () => {
|
|
349
|
+
badgeDrawer.style.display = 'none';
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
stage.appendChild(el);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (frame.node.type === 'box' && frame.node.style && frame.node.style.borderStyle) {
|
|
359
|
+
const box = document.createElement('div');
|
|
360
|
+
box.style.position = 'absolute';
|
|
361
|
+
box.style.left = `calc(var(--cell-w) * ${x})`;
|
|
362
|
+
box.style.top = `calc(var(--cell-h) * ${y})`;
|
|
363
|
+
box.style.width = `calc(var(--cell-w) * ${frame.width})`;
|
|
364
|
+
box.style.height = `calc(var(--cell-h) * ${frame.height})`;
|
|
365
|
+
box.style.borderStyle = 'solid';
|
|
366
|
+
box.style.borderWidth = '1px';
|
|
367
|
+
box.style.borderColor = colorMap(frame.node.style.borderColor || 'gray');
|
|
368
|
+
box.style.pointerEvents = 'none';
|
|
369
|
+
stage.appendChild(box);
|
|
370
|
+
} else if (debugBorders) {
|
|
371
|
+
const box = document.createElement('div');
|
|
372
|
+
box.style.position = 'absolute';
|
|
373
|
+
box.style.left = `calc(var(--cell-w) * ${x})`;
|
|
374
|
+
box.style.top = `calc(var(--cell-h) * ${y})`;
|
|
375
|
+
box.style.width = `calc(var(--cell-w) * ${frame.width})`;
|
|
376
|
+
box.style.height = `calc(var(--cell-h) * ${frame.height})`;
|
|
377
|
+
box.style.borderStyle = 'dashed';
|
|
378
|
+
box.style.borderWidth = '1px';
|
|
379
|
+
box.style.borderColor = '#3a3a3a';
|
|
380
|
+
box.style.pointerEvents = 'none';
|
|
381
|
+
stage.appendChild(box);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const child of frame.children || []) {
|
|
385
|
+
renderFrame(child, x, y);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const es = new EventSource(`/events?id=${clientId}`);
|
|
390
|
+
es.onmessage = (evt) => {
|
|
391
|
+
lastFrame = JSON.parse(evt.data);
|
|
392
|
+
if (!selectionActive) {
|
|
393
|
+
renderLatest();
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
function toInputEvent(evt) {
|
|
398
|
+
const key = {};
|
|
399
|
+
let input = '';
|
|
400
|
+
|
|
401
|
+
if (evt.ctrlKey) key.ctrl = true;
|
|
402
|
+
if (evt.metaKey) key.meta = true;
|
|
403
|
+
if (evt.shiftKey) key.shift = true;
|
|
404
|
+
|
|
405
|
+
switch (evt.key) {
|
|
406
|
+
case 'Enter':
|
|
407
|
+
key.return = true;
|
|
408
|
+
break;
|
|
409
|
+
case 'Backspace':
|
|
410
|
+
key.backspace = true;
|
|
411
|
+
break;
|
|
412
|
+
case 'Delete':
|
|
413
|
+
key.delete = true;
|
|
414
|
+
break;
|
|
415
|
+
case 'ArrowUp':
|
|
416
|
+
key.upArrow = true;
|
|
417
|
+
break;
|
|
418
|
+
case 'ArrowDown':
|
|
419
|
+
key.downArrow = true;
|
|
420
|
+
break;
|
|
421
|
+
case 'ArrowLeft':
|
|
422
|
+
key.leftArrow = true;
|
|
423
|
+
break;
|
|
424
|
+
case 'ArrowRight':
|
|
425
|
+
key.rightArrow = true;
|
|
426
|
+
break;
|
|
427
|
+
default:
|
|
428
|
+
if (evt.key && evt.key.length === 1) {
|
|
429
|
+
input = evt.key;
|
|
430
|
+
if (evt.ctrlKey || evt.metaKey) {
|
|
431
|
+
input = evt.key.toLowerCase();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { input, key };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
let inputEnabled = true;
|
|
440
|
+
let hasFocus = false;
|
|
441
|
+
|
|
442
|
+
function sendInput(payload) {
|
|
443
|
+
fetch('/input', {
|
|
444
|
+
method: 'POST',
|
|
445
|
+
headers: { 'Content-Type': 'application/json' },
|
|
446
|
+
body: JSON.stringify(payload)
|
|
447
|
+
}).catch(() => {});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let toastTimer = null;
|
|
451
|
+
function showToast(message) {
|
|
452
|
+
toast.textContent = message;
|
|
453
|
+
toast.classList.add('show');
|
|
454
|
+
if (toastTimer) {
|
|
455
|
+
clearTimeout(toastTimer);
|
|
456
|
+
}
|
|
457
|
+
toastTimer = setTimeout(() => {
|
|
458
|
+
toast.classList.remove('show');
|
|
459
|
+
}, 2500);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function formatPathForInput(value) {
|
|
463
|
+
if (!value) return value;
|
|
464
|
+
if (value.includes(' ')) {
|
|
465
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
466
|
+
}
|
|
467
|
+
return value;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function uploadFile(file) {
|
|
471
|
+
try {
|
|
472
|
+
const res = await fetch('/upload', {
|
|
473
|
+
method: 'POST',
|
|
474
|
+
headers: {
|
|
475
|
+
'Content-Type': 'application/octet-stream',
|
|
476
|
+
'X-Filename': file.name || 'upload.bin'
|
|
477
|
+
},
|
|
478
|
+
body: file
|
|
479
|
+
});
|
|
480
|
+
if (!res.ok) return null;
|
|
481
|
+
const data = await res.json();
|
|
482
|
+
return typeof data.path === 'string' ? data.path : null;
|
|
483
|
+
} catch {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function handleFiles(files) {
|
|
489
|
+
if (!files || files.length === 0) return;
|
|
490
|
+
const paths = [];
|
|
491
|
+
for (const file of Array.from(files)) {
|
|
492
|
+
const path = await uploadFile(file);
|
|
493
|
+
if (path) paths.push(path);
|
|
494
|
+
}
|
|
495
|
+
if (paths.length > 0) {
|
|
496
|
+
const text = `${paths.map(formatPathForInput).join(' ')} `;
|
|
497
|
+
sendInput({ input: text, key: {} });
|
|
498
|
+
showToast(paths.length === 1 ? `Saved file to ${paths[0]}` : `Saved ${paths.length} files`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function handleKey(evt) {
|
|
503
|
+
if ((evt.ctrlKey || evt.metaKey) && evt.key.toLowerCase() === 'c') {
|
|
504
|
+
const selection = window.getSelection();
|
|
505
|
+
if (selection && selection.toString()) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if ((evt.ctrlKey || evt.metaKey) && evt.key.toLowerCase() === 'v') {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (evt.ctrlKey && evt.shiftKey && evt.key.toLowerCase() === 'd') {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (!inputEnabled || !hasFocus) return;
|
|
516
|
+
const payload = toInputEvent(evt);
|
|
517
|
+
if (!payload.input && Object.keys(payload.key).length === 0) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
sendInput(payload);
|
|
522
|
+
evt.preventDefault();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function renderOverlay() {
|
|
526
|
+
const focusText = hasFocus ? 'Focused' : 'Click to focus';
|
|
527
|
+
const inputText = inputEnabled ? 'Input on' : 'Input off';
|
|
528
|
+
const debugText = debugBorders ? 'Debug on' : 'Debug off';
|
|
529
|
+
overlay.textContent = `${focusText} · ${inputText} · ${debugText} · Ctrl+M toggle · Ctrl+Shift+D`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function setFocus(value) {
|
|
533
|
+
hasFocus = value;
|
|
534
|
+
stage.style.outline = hasFocus ? '1px dashed #60a5fa' : 'none';
|
|
535
|
+
renderOverlay();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
stage.addEventListener('click', () => setFocus(true));
|
|
539
|
+
window.addEventListener('click', (evt) => {
|
|
540
|
+
if (!stage.contains(evt.target)) {
|
|
541
|
+
setFocus(false);
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
window.addEventListener('keydown', (evt) => {
|
|
546
|
+
if (evt.key === 'Escape') {
|
|
547
|
+
setFocus(false);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
window.addEventListener('keydown', handleKey);
|
|
552
|
+
|
|
553
|
+
window.addEventListener('paste', (evt) => {
|
|
554
|
+
if (!inputEnabled) return;
|
|
555
|
+
if (!hasFocus) {
|
|
556
|
+
setFocus(true);
|
|
557
|
+
}
|
|
558
|
+
const items = evt.clipboardData && evt.clipboardData.items;
|
|
559
|
+
if (items) {
|
|
560
|
+
const imageItem = Array.from(items).find(item => item.type && item.type.startsWith('image/'));
|
|
561
|
+
if (imageItem) {
|
|
562
|
+
const file = imageItem.getAsFile();
|
|
563
|
+
if (file) {
|
|
564
|
+
evt.preventDefault();
|
|
565
|
+
uploadFile(file).then(path => {
|
|
566
|
+
if (path) {
|
|
567
|
+
const text = `${formatPathForInput(path)} `;
|
|
568
|
+
sendInput({ input: text, key: {} });
|
|
569
|
+
showToast(`Image saved to ${path}`);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const text = evt.clipboardData && evt.clipboardData.getData('text');
|
|
577
|
+
if (text) {
|
|
578
|
+
sendInput({ input: text, key: {} });
|
|
579
|
+
evt.preventDefault();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
583
|
+
navigator.clipboard.readText().then(value => {
|
|
584
|
+
if (value) {
|
|
585
|
+
sendInput({ input: value, key: {} });
|
|
586
|
+
}
|
|
587
|
+
}).catch(() => {});
|
|
588
|
+
evt.preventDefault();
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
window.addEventListener('dragover', (evt) => {
|
|
593
|
+
if (!hasFocus) return;
|
|
594
|
+
if (evt.dataTransfer && Array.from(evt.dataTransfer.types || []).includes('Files')) {
|
|
595
|
+
evt.preventDefault();
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
window.addEventListener('drop', (evt) => {
|
|
600
|
+
if (!hasFocus) return;
|
|
601
|
+
const files = evt.dataTransfer && evt.dataTransfer.files;
|
|
602
|
+
if (files && files.length > 0) {
|
|
603
|
+
evt.preventDefault();
|
|
604
|
+
handleFiles(files);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
window.addEventListener('keydown', (evt) => {
|
|
609
|
+
if (!evt.ctrlKey || evt.key.toLowerCase() !== 'm') return;
|
|
610
|
+
inputEnabled = !inputEnabled;
|
|
611
|
+
renderOverlay();
|
|
612
|
+
evt.preventDefault();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
window.addEventListener('keydown', (evt) => {
|
|
616
|
+
if (!evt.ctrlKey || !evt.shiftKey) return;
|
|
617
|
+
if (evt.key.toLowerCase() !== 'd') return;
|
|
618
|
+
debugBorders = !debugBorders;
|
|
619
|
+
renderOverlay();
|
|
620
|
+
renderLatest();
|
|
621
|
+
evt.preventDefault();
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
window.addEventListener('resize', () => {
|
|
625
|
+
cell = measureCell();
|
|
626
|
+
sendSize();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
document.addEventListener('selectionchange', () => {
|
|
630
|
+
const selection = window.getSelection();
|
|
631
|
+
selectionActive = !!(selection && selection.toString());
|
|
632
|
+
if (!selectionActive) {
|
|
633
|
+
renderLatest();
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
renderOverlay();
|
|
638
|
+
sendSize();
|
|
639
|
+
</script>
|
|
640
|
+
</body>
|
|
641
|
+
</html>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { InputBus } from '../ui/core/input.js';
|
|
3
|
+
import { LayoutNode } from '../ui/core/index.js';
|
|
4
|
+
import { MirrorServer } from './mirror_server.js';
|
|
5
|
+
|
|
6
|
+
let server: MirrorServer | null = null;
|
|
7
|
+
|
|
8
|
+
function getPort(): number {
|
|
9
|
+
const raw = process.env.ZTC_WEB_PORT;
|
|
10
|
+
if (!raw) return 3939;
|
|
11
|
+
const parsed = Number(raw);
|
|
12
|
+
return Number.isFinite(parsed) ? parsed : 3939;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useMirror(layout: LayoutNode | null, inputBus?: InputBus): void {
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (process.env.ZTC_WEB_MIRROR !== '1' || !layout) return;
|
|
18
|
+
|
|
19
|
+
if (!server) {
|
|
20
|
+
server = new MirrorServer(getPort(), inputBus);
|
|
21
|
+
server.start();
|
|
22
|
+
}
|
|
23
|
+
server.publish(layout);
|
|
24
|
+
}, [inputBus, layout]);
|
|
25
|
+
}
|