design-share 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DesignShare.app/Contents/CodeResources +0 -0
- package/dist/DesignShare.app/Contents/Info.plist +2 -2
- package/dist/DesignShare.app/Contents/MacOS/DesignShare +0 -0
- package/package.json +1 -1
- package/public/app.js +149 -48
- package/public/index.html +4 -0
- package/public/inspect.js +233 -0
- package/public/style.css +36 -0
- package/src/previews.js +29 -5
- package/src/proxy.js +94 -0
- package/src/server.js +4 -0
|
Binary file
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
<key>CFBundleIdentifier</key>
|
|
10
10
|
<string>com.design-share.menubar</string>
|
|
11
11
|
<key>CFBundleVersion</key>
|
|
12
|
-
<string>0.1.
|
|
12
|
+
<string>0.1.1</string>
|
|
13
13
|
<key>CFBundleShortVersionString</key>
|
|
14
|
-
<string>0.1.
|
|
14
|
+
<string>0.1.1</string>
|
|
15
15
|
<key>CFBundleExecutable</key>
|
|
16
16
|
<string>DesignShare</string>
|
|
17
17
|
<key>CFBundlePackageType</key>
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "design-share",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Live branch previews and pinned comments for design teams. Zero config, no server, git is the backend.",
|
|
5
5
|
"keywords": ["design", "preview", "branches", "collaboration", "cli"],
|
|
6
6
|
"license": "MIT",
|
package/public/app.js
CHANGED
|
@@ -15,10 +15,13 @@ const state = {
|
|
|
15
15
|
selected: null, // { user, branch }
|
|
16
16
|
viewport: 'desktop',
|
|
17
17
|
commentMode: false,
|
|
18
|
-
pendingPin: null, // { xPct, yPct }
|
|
18
|
+
pendingPin: null, // { xPct, yPct, selector?, elementLabel?, relX?, relY?, route? }
|
|
19
19
|
focusedComment: null,
|
|
20
20
|
previewStatus: null,
|
|
21
21
|
frameUrl: null,
|
|
22
|
+
bridge: false, // true when the injected inspector answered from inside the iframe
|
|
23
|
+
route: '/',
|
|
24
|
+
railFilter: '',
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
const VIEWPORTS = [
|
|
@@ -79,62 +82,81 @@ function renderRail() {
|
|
|
79
82
|
|
|
80
83
|
const sections = el('rail-sections');
|
|
81
84
|
const frag = document.createDocumentFragment();
|
|
85
|
+
const filter = (state.railFilter || '').trim().toLowerCase();
|
|
86
|
+
|
|
87
|
+
// One section per person. Shared branches render bright; branches that
|
|
88
|
+
// exist on origin without being shared render dim under the same author.
|
|
89
|
+
const people = new Map(); // slug -> { slug, name, items: [{user, branch, time, shared}] }
|
|
90
|
+
const person = (slug, name) => {
|
|
91
|
+
if (!people.has(slug)) people.set(slug, { slug, name, items: [] });
|
|
92
|
+
return people.get(slug);
|
|
93
|
+
};
|
|
82
94
|
|
|
83
95
|
const shares = b.shares.slice().sort((x, y) => (y.updatedAt || 0) - (x.updatedAt || 0));
|
|
84
|
-
const byUser = new Map();
|
|
85
96
|
for (const s of shares) {
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
person(s.user, s.name).items.push({
|
|
98
|
+
user: s.user, branch: s.branch, shared: true,
|
|
99
|
+
time: b.heads[s.branch] ? b.heads[s.branch].time : s.updatedAt / 1000,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
for (const o of b.otherBranches) {
|
|
103
|
+
const slug = slugify(o.author);
|
|
104
|
+
person(slug, people.has(slug) ? people.get(slug).name : (o.author || 'someone'))
|
|
105
|
+
.items.push({ user: slug, branch: o.branch, shared: false, time: o.time });
|
|
88
106
|
}
|
|
89
107
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
const ownShared = byUser.has(b.me.slug) && byUser.get(b.me.slug).items.some((s) => s.branch === b.ownBranch);
|
|
108
|
+
const me = people.get(b.me.slug);
|
|
109
|
+
const ownShared = !!(me && me.items.some((i) => i.branch === b.ownBranch && i.shared));
|
|
94
110
|
if (b.ownBranch && !ownShared) {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
frag.appendChild(branchRow({
|
|
100
|
-
user: b.me.slug, branch: b.ownBranch, own: true,
|
|
111
|
+
const mine = person(b.me.slug, b.me.name);
|
|
112
|
+
mine.items = mine.items.filter((i) => i.branch !== b.ownBranch);
|
|
113
|
+
mine.items.unshift({
|
|
114
|
+
user: b.me.slug, branch: b.ownBranch, shared: true, own: true,
|
|
101
115
|
time: b.heads[b.ownBranch] ? b.heads[b.ownBranch].time : null,
|
|
102
|
-
})
|
|
103
|
-
const cta = document.createElement('button');
|
|
104
|
-
cta.className = 'share-cta';
|
|
105
|
-
cta.innerHTML = `${icons.plus} share ${escapeHtml(b.ownBranch)} with the board`;
|
|
106
|
-
cta.onclick = async () => {
|
|
107
|
-
await api('/api/share', { branch: b.ownBranch });
|
|
108
|
-
toast('Shared with the team board');
|
|
109
|
-
refresh();
|
|
110
|
-
};
|
|
111
|
-
frag.appendChild(cta);
|
|
116
|
+
});
|
|
112
117
|
}
|
|
113
118
|
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
const latest = (p) => Math.max(...p.items.map((i) => i.time || 0), 0);
|
|
120
|
+
const order = [...people.values()].sort((x, y) => {
|
|
121
|
+
if (x.slug === b.me.slug) return -1;
|
|
122
|
+
if (y.slug === b.me.slug) return 1;
|
|
123
|
+
const xs = x.items.some((i) => i.shared), ys = y.items.some((i) => i.shared);
|
|
124
|
+
if (xs !== ys) return xs ? -1 : 1;
|
|
125
|
+
return latest(y) - latest(x);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let shown = 0;
|
|
129
|
+
for (const p of order) {
|
|
130
|
+
const items = p.items.filter((i) =>
|
|
131
|
+
!filter || i.branch.toLowerCase().includes(filter) || p.name.toLowerCase().includes(filter));
|
|
132
|
+
if (!items.length) continue;
|
|
133
|
+
shown += items.length;
|
|
134
|
+
|
|
116
135
|
const label = document.createElement('div');
|
|
117
136
|
label.className = 'rail-section-label';
|
|
118
|
-
label.textContent =
|
|
137
|
+
label.textContent = p.slug === b.me.slug ? `${p.name} (you)` : p.name;
|
|
119
138
|
frag.appendChild(label);
|
|
120
|
-
for (const
|
|
121
|
-
frag.appendChild(branchRow({
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
for (const i of items) {
|
|
140
|
+
frag.appendChild(branchRow({ user: i.user, branch: i.branch, time: i.time, dim: !i.shared }));
|
|
141
|
+
}
|
|
142
|
+
if (p.slug === b.me.slug && b.ownBranch && !ownShared && (!filter || b.ownBranch.toLowerCase().includes(filter))) {
|
|
143
|
+
const cta = document.createElement('button');
|
|
144
|
+
cta.className = 'share-cta';
|
|
145
|
+
cta.innerHTML = `${icons.plus} share ${escapeHtml(b.ownBranch)} with the board`;
|
|
146
|
+
cta.onclick = async () => {
|
|
147
|
+
await api('/api/share', { branch: b.ownBranch });
|
|
148
|
+
toast('Shared with the team board');
|
|
149
|
+
refresh();
|
|
150
|
+
};
|
|
151
|
+
frag.appendChild(cta);
|
|
125
152
|
}
|
|
126
153
|
}
|
|
127
154
|
|
|
128
|
-
if (
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
frag.appendChild(
|
|
133
|
-
for (const o of b.otherBranches.slice(0, 12)) {
|
|
134
|
-
frag.appendChild(branchRow({
|
|
135
|
-
user: slugify(o.author), branch: o.branch, time: o.time, dim: true,
|
|
136
|
-
}));
|
|
137
|
-
}
|
|
155
|
+
if (!shown) {
|
|
156
|
+
const empty = document.createElement('div');
|
|
157
|
+
empty.className = 'empty-note';
|
|
158
|
+
empty.textContent = filter ? 'Nothing matches this filter.' : 'No branches yet.';
|
|
159
|
+
frag.appendChild(empty);
|
|
138
160
|
}
|
|
139
161
|
|
|
140
162
|
sections.replaceChildren(frag);
|
|
@@ -183,6 +205,7 @@ async function select(user, branch) {
|
|
|
183
205
|
state.pendingPin = null;
|
|
184
206
|
state.focusedComment = null;
|
|
185
207
|
state.frameUrl = null;
|
|
208
|
+
state.bridge = false;
|
|
186
209
|
location.hash = `#/u/${encodeURIComponent(user)}/${encodeURIComponent(branch)}`;
|
|
187
210
|
el('frame').hidden = true;
|
|
188
211
|
el('frame').src = 'about:blank';
|
|
@@ -228,7 +251,9 @@ function renderStage() {
|
|
|
228
251
|
}
|
|
229
252
|
|
|
230
253
|
const share = b.shares.find((s) => s.user === sel.user && s.branch === sel.branch);
|
|
231
|
-
const authorName = share ? share.name
|
|
254
|
+
const authorName = share ? share.name
|
|
255
|
+
: sel.user === b.me.slug ? b.me.name
|
|
256
|
+
: (b.heads[sel.branch] && b.heads[sel.branch].author) || sel.user;
|
|
232
257
|
const head = b.heads[sel.branch];
|
|
233
258
|
el('stage-title').textContent = sel.branch;
|
|
234
259
|
el('stage-meta').textContent = `${authorName}${head ? ' · updated ' + timeAgo(head.time) : ''}${sel.user === b.me.slug && sel.branch === b.ownBranch ? ' · your working copy' : ''}`;
|
|
@@ -263,19 +288,78 @@ function renderStage() {
|
|
|
263
288
|
}
|
|
264
289
|
|
|
265
290
|
const cm = el('btn-comment-mode');
|
|
266
|
-
cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click to
|
|
291
|
+
cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click an element to pin' : 'Comment'}</span>`;
|
|
267
292
|
cm.classList.toggle('active-mode', state.commentMode);
|
|
268
|
-
|
|
293
|
+
// The in-page inspector handles hover + capture when present; the overlay
|
|
294
|
+
// only captures clicks as a fallback for previews the proxy could not reach.
|
|
295
|
+
el('pin-layer').classList.toggle('capture', state.commentMode && !state.bridge);
|
|
296
|
+
sendToFrame({ ds: 'mode', comment: state.commentMode });
|
|
269
297
|
|
|
270
298
|
renderPins();
|
|
271
299
|
}
|
|
272
300
|
|
|
301
|
+
function sendToFrame(msg) {
|
|
302
|
+
const frame = el('frame');
|
|
303
|
+
if (frame.contentWindow) {
|
|
304
|
+
try { frame.contentWindow.postMessage(msg, '*'); } catch { /* not ready */ }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function pushCommentsToFrame() {
|
|
309
|
+
const sel = state.selected;
|
|
310
|
+
if (!sel || !state.bridge) return;
|
|
311
|
+
const items = branchComments(sel.branch).map((c, i) => ({
|
|
312
|
+
id: c.id,
|
|
313
|
+
n: i + 1,
|
|
314
|
+
selector: c.selector || null,
|
|
315
|
+
relX: c.relX,
|
|
316
|
+
relY: c.relY,
|
|
317
|
+
xPct: c.xPct,
|
|
318
|
+
yPct: c.yPct,
|
|
319
|
+
resolved: !!c.resolvedAt,
|
|
320
|
+
}));
|
|
321
|
+
sendToFrame({ ds: 'comments', items, focusedId: state.focusedComment });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
window.addEventListener('message', (e) => {
|
|
325
|
+
const frame = el('frame');
|
|
326
|
+
if (e.source !== frame.contentWindow) return;
|
|
327
|
+
const d = e.data || {};
|
|
328
|
+
if (d.ds === 'ready') {
|
|
329
|
+
state.bridge = true;
|
|
330
|
+
state.route = d.route || '/';
|
|
331
|
+
sendToFrame({ ds: 'mode', comment: state.commentMode });
|
|
332
|
+
pushCommentsToFrame();
|
|
333
|
+
renderStage();
|
|
334
|
+
} else if (d.ds === 'pin') {
|
|
335
|
+
state.pendingPin = {
|
|
336
|
+
xPct: d.xPct, yPct: d.yPct,
|
|
337
|
+
selector: d.selector, elementLabel: d.elementLabel,
|
|
338
|
+
relX: d.relX, relY: d.relY, route: d.route,
|
|
339
|
+
};
|
|
340
|
+
const composer = el('composer');
|
|
341
|
+
composer.hidden = false;
|
|
342
|
+
const wrap = el('frame-wrap');
|
|
343
|
+
composer.style.left = Math.min(d.clientX, wrap.clientWidth - 260) + 'px';
|
|
344
|
+
composer.style.top = Math.min(d.clientY + 14, wrap.clientHeight - 120) + 'px';
|
|
345
|
+
el('composer-text').value = '';
|
|
346
|
+
el('composer-text').focus();
|
|
347
|
+
} else if (d.ds === 'pin-click') {
|
|
348
|
+
state.focusedComment = d.id;
|
|
349
|
+
renderComments();
|
|
350
|
+
pushCommentsToFrame();
|
|
351
|
+
const card = document.querySelector(`[data-comment="${d.id}"]`);
|
|
352
|
+
if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
273
356
|
/* ---------- pins ---------- */
|
|
274
357
|
|
|
275
358
|
function renderPins() {
|
|
276
359
|
const layer = el('pin-layer');
|
|
277
360
|
layer.querySelectorAll('.pin').forEach((p) => p.remove());
|
|
278
361
|
const sel = state.selected;
|
|
362
|
+
if (state.bridge) { pushCommentsToFrame(); return; }
|
|
279
363
|
if (!sel || el('frame').hidden) return;
|
|
280
364
|
const comments = branchComments(sel.branch);
|
|
281
365
|
comments.forEach((c, i) => {
|
|
@@ -317,12 +401,17 @@ async function postComment() {
|
|
|
317
401
|
const sel = state.selected;
|
|
318
402
|
if (!text || !sel || !state.pendingPin) return;
|
|
319
403
|
const rect = el('pin-layer').getBoundingClientRect();
|
|
404
|
+
const pin = state.pendingPin;
|
|
320
405
|
await api('/api/comments', {
|
|
321
406
|
targetUser: sel.user,
|
|
322
407
|
branch: sel.branch,
|
|
323
|
-
route:
|
|
324
|
-
xPct:
|
|
325
|
-
yPct:
|
|
408
|
+
route: pin.route || state.route || '/',
|
|
409
|
+
xPct: pin.xPct,
|
|
410
|
+
yPct: pin.yPct,
|
|
411
|
+
selector: pin.selector || null,
|
|
412
|
+
elementLabel: pin.elementLabel || null,
|
|
413
|
+
relX: pin.relX,
|
|
414
|
+
relY: pin.relY,
|
|
326
415
|
viewportLabel: state.viewport,
|
|
327
416
|
viewportW: Math.round(rect.width),
|
|
328
417
|
viewportH: Math.round(rect.height),
|
|
@@ -400,6 +489,7 @@ function commentCard(c, num) {
|
|
|
400
489
|
<span class="comment-num">${num}</span>
|
|
401
490
|
</div>
|
|
402
491
|
<div class="comment-text">${escapeHtml(c.text)}</div>
|
|
492
|
+
${c.elementLabel ? `<div class="comment-anchor">${escapeHtml(c.elementLabel)}</div>` : ''}
|
|
403
493
|
<div class="comment-meta">
|
|
404
494
|
<span>${timeAgo(c.createdAt / 1000)}</span>
|
|
405
495
|
${c.viewportLabel ? `<span>· ${escapeHtml(c.viewportLabel)}</span>` : ''}
|
|
@@ -443,6 +533,7 @@ function commentCard(c, num) {
|
|
|
443
533
|
if (e.target === reply || e.target.tagName === 'BUTTON') return;
|
|
444
534
|
state.focusedComment = c.id;
|
|
445
535
|
renderPins();
|
|
536
|
+
pushCommentsToFrame();
|
|
446
537
|
document.querySelectorAll('.comment-card').forEach((x) => x.classList.remove('focused'));
|
|
447
538
|
card.classList.add('focused');
|
|
448
539
|
};
|
|
@@ -514,6 +605,16 @@ function parseHash() {
|
|
|
514
605
|
}
|
|
515
606
|
|
|
516
607
|
(async function boot() {
|
|
608
|
+
// Each iframe navigation starts a fresh document; assume no inspector until
|
|
609
|
+
// the injected script announces itself again.
|
|
610
|
+
el('frame').addEventListener('load', () => {
|
|
611
|
+
state.bridge = false;
|
|
612
|
+
setTimeout(() => renderStage(), 300);
|
|
613
|
+
});
|
|
614
|
+
el('branch-filter').addEventListener('input', (e) => {
|
|
615
|
+
state.railFilter = e.target.value;
|
|
616
|
+
renderRail();
|
|
617
|
+
});
|
|
517
618
|
renderViewportSeg();
|
|
518
619
|
await refresh();
|
|
519
620
|
const target = parseHash();
|
package/public/index.html
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
<span class="logo-mark"></span>
|
|
14
14
|
<span id="repo-name" class="repo-name">…</span>
|
|
15
15
|
</div>
|
|
16
|
+
<div class="rail-filter">
|
|
17
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
|
18
|
+
<input id="branch-filter" placeholder="Filter people or branches" spellcheck="false" autocomplete="off">
|
|
19
|
+
</div>
|
|
16
20
|
<div id="rail-sections"></div>
|
|
17
21
|
<div class="rail-foot">
|
|
18
22
|
<div id="sync-status" class="sync-status"></div>
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/* design-share in-page inspector.
|
|
2
|
+
Injected into previews by the daemon proxy. Draws the hover highlight and
|
|
3
|
+
tooltip during comment mode, anchors pins to real elements, and talks to
|
|
4
|
+
the dashboard through postMessage. Inert when not framed by the dashboard. */
|
|
5
|
+
(function () {
|
|
6
|
+
'use strict';
|
|
7
|
+
if (window.top === window || window.__designShareInspector) return;
|
|
8
|
+
window.__designShareInspector = true;
|
|
9
|
+
|
|
10
|
+
var Z = 2147483000;
|
|
11
|
+
var commentMode = false;
|
|
12
|
+
var comments = [];
|
|
13
|
+
var focusedId = null;
|
|
14
|
+
|
|
15
|
+
/* ---------- chrome (highlight box, tooltip, pin layer) ---------- */
|
|
16
|
+
|
|
17
|
+
var css = [
|
|
18
|
+
'.__ds-box{position:fixed;pointer-events:none;z-index:' + Z + ';border:1.5px solid #2f7cf6;',
|
|
19
|
+
'border-radius:3px;background:rgba(47,124,246,0.06);display:none;box-sizing:border-box;}',
|
|
20
|
+
'.__ds-tip{position:fixed;pointer-events:none;z-index:' + (Z + 2) + ';background:#16161a;color:#fff;',
|
|
21
|
+
'font:12px/1.45 -apple-system,BlinkMacSystemFont,sans-serif;padding:6px 10px;border-radius:7px;',
|
|
22
|
+
'max-width:340px;box-shadow:0 4px 16px rgba(0,0,0,0.3);display:none;white-space:nowrap;',
|
|
23
|
+
'overflow:hidden;text-overflow:ellipsis;}',
|
|
24
|
+
'.__ds-tip b{font-weight:600;color:#9ec2ff;}',
|
|
25
|
+
'.__ds-pin{position:fixed;z-index:' + (Z + 1) + ';width:20px;height:20px;border-radius:50% 50% 50% 0;',
|
|
26
|
+
'transform:translate(-3px,-17px) rotate(-45deg);background:#2f7cf6;color:#fff;border:2px solid #fff;',
|
|
27
|
+
'box-shadow:0 1px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;',
|
|
28
|
+
'font:600 10px -apple-system,sans-serif;cursor:pointer;}',
|
|
29
|
+
'.__ds-pin>span{transform:rotate(45deg);}',
|
|
30
|
+
'.__ds-pin.__ds-resolved{background:#2da562;opacity:0.55;}',
|
|
31
|
+
'.__ds-pin.__ds-focused{outline:2px solid #2f7cf6;outline-offset:2px;}',
|
|
32
|
+
'.__ds-comment-cursor,.__ds-comment-cursor *{cursor:crosshair !important;}',
|
|
33
|
+
].join('');
|
|
34
|
+
var style = document.createElement('style');
|
|
35
|
+
style.textContent = css;
|
|
36
|
+
document.documentElement.appendChild(style);
|
|
37
|
+
|
|
38
|
+
var box = document.createElement('div');
|
|
39
|
+
box.className = '__ds-box';
|
|
40
|
+
var tip = document.createElement('div');
|
|
41
|
+
tip.className = '__ds-tip';
|
|
42
|
+
document.documentElement.appendChild(box);
|
|
43
|
+
document.documentElement.appendChild(tip);
|
|
44
|
+
var pinLayer = document.createElement('div');
|
|
45
|
+
document.documentElement.appendChild(pinLayer);
|
|
46
|
+
|
|
47
|
+
/* ---------- element naming, selectors ---------- */
|
|
48
|
+
|
|
49
|
+
var FRIENDLY = {
|
|
50
|
+
p: 'paragraph', a: 'link', img: 'image', button: 'button', input: 'input',
|
|
51
|
+
textarea: 'input', select: 'input', ul: 'list', ol: 'list', li: 'list item',
|
|
52
|
+
nav: 'navigation', svg: 'icon', video: 'video', form: 'form', table: 'table',
|
|
53
|
+
label: 'label', header: 'header', footer: 'footer', section: 'section',
|
|
54
|
+
};
|
|
55
|
+
function friendlyName(el) {
|
|
56
|
+
var t = el.tagName.toLowerCase();
|
|
57
|
+
if (/^h[1-6]$/.test(t)) return 'heading';
|
|
58
|
+
return FRIENDLY[t] || t;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function labelFor(el) {
|
|
62
|
+
var name = friendlyName(el);
|
|
63
|
+
var text = (el.innerText || el.getAttribute('alt') || el.getAttribute('aria-label') || '')
|
|
64
|
+
.replace(/\s+/g, ' ').trim();
|
|
65
|
+
if (text.length > 42) text = text.slice(0, 42) + '…';
|
|
66
|
+
return { name: name, text: text };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cssPath(el) {
|
|
70
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
71
|
+
var parts = [];
|
|
72
|
+
var node = el;
|
|
73
|
+
while (node && node.nodeType === 1 && node !== document.body && parts.length < 6) {
|
|
74
|
+
var tag = node.tagName.toLowerCase();
|
|
75
|
+
if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; }
|
|
76
|
+
var index = 1, sib = node;
|
|
77
|
+
while ((sib = sib.previousElementSibling)) {
|
|
78
|
+
if (sib.tagName === node.tagName) index++;
|
|
79
|
+
}
|
|
80
|
+
parts.unshift(tag + ':nth-of-type(' + index + ')');
|
|
81
|
+
node = node.parentElement;
|
|
82
|
+
}
|
|
83
|
+
return parts.join(' > ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isOurs(el) {
|
|
87
|
+
return el && el.className && typeof el.className === 'string' && el.className.indexOf('__ds-') !== -1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ---------- hover highlight ---------- */
|
|
91
|
+
|
|
92
|
+
var hoverEl = null;
|
|
93
|
+
|
|
94
|
+
function showHighlight(el, x, y) {
|
|
95
|
+
var r = el.getBoundingClientRect();
|
|
96
|
+
box.style.display = 'block';
|
|
97
|
+
box.style.left = r.left + 'px';
|
|
98
|
+
box.style.top = r.top + 'px';
|
|
99
|
+
box.style.width = r.width + 'px';
|
|
100
|
+
box.style.height = r.height + 'px';
|
|
101
|
+
|
|
102
|
+
var info = labelFor(el);
|
|
103
|
+
tip.innerHTML = '<b>' + info.name + '</b>' + (info.text ? ': “' + escapeHtml(info.text) + '”' : '');
|
|
104
|
+
tip.style.display = 'block';
|
|
105
|
+
var tw = tip.offsetWidth, th = tip.offsetHeight;
|
|
106
|
+
var tx = Math.min(Math.max(8, x + 12), window.innerWidth - tw - 8);
|
|
107
|
+
var ty = y + 16 + th > window.innerHeight - 8 ? y - th - 10 : y + 16;
|
|
108
|
+
tip.style.left = tx + 'px';
|
|
109
|
+
tip.style.top = ty + 'px';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function hideHighlight() {
|
|
113
|
+
box.style.display = 'none';
|
|
114
|
+
tip.style.display = 'none';
|
|
115
|
+
hoverEl = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function escapeHtml(s) {
|
|
119
|
+
return s.replace(/[&<>"]/g, function (c) {
|
|
120
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
document.addEventListener('mousemove', function (e) {
|
|
125
|
+
if (!commentMode) return;
|
|
126
|
+
var el = e.target;
|
|
127
|
+
if (isOurs(el) || el === document.body || el === document.documentElement) { hideHighlight(); return; }
|
|
128
|
+
hoverEl = el;
|
|
129
|
+
showHighlight(el, e.clientX, e.clientY);
|
|
130
|
+
}, true);
|
|
131
|
+
|
|
132
|
+
document.addEventListener('mouseleave', hideHighlight, true);
|
|
133
|
+
|
|
134
|
+
/* ---------- click to pin ---------- */
|
|
135
|
+
|
|
136
|
+
['click', 'mousedown', 'mouseup'].forEach(function (type) {
|
|
137
|
+
document.addEventListener(type, function (e) {
|
|
138
|
+
if (!commentMode) return;
|
|
139
|
+
if (isOurs(e.target)) return; // pins stay clickable
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
e.stopPropagation();
|
|
142
|
+
if (type !== 'click') return;
|
|
143
|
+
var el = hoverEl || e.target;
|
|
144
|
+
var r = el.getBoundingClientRect();
|
|
145
|
+
var info = labelFor(el);
|
|
146
|
+
parent.postMessage({
|
|
147
|
+
ds: 'pin',
|
|
148
|
+
selector: cssPath(el),
|
|
149
|
+
elementLabel: info.name + (info.text ? ': “' + info.text + '”' : ''),
|
|
150
|
+
relX: r.width ? (e.clientX - r.left) / r.width : 0.5,
|
|
151
|
+
relY: r.height ? (e.clientY - r.top) / r.height : 0.5,
|
|
152
|
+
xPct: (e.clientX / window.innerWidth) * 100,
|
|
153
|
+
yPct: (e.clientY / window.innerHeight) * 100,
|
|
154
|
+
clientX: e.clientX,
|
|
155
|
+
clientY: e.clientY,
|
|
156
|
+
route: location.pathname,
|
|
157
|
+
}, '*');
|
|
158
|
+
hideHighlight();
|
|
159
|
+
}, true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/* ---------- pins anchored to elements ---------- */
|
|
163
|
+
|
|
164
|
+
function renderPins() {
|
|
165
|
+
pinLayer.innerHTML = '';
|
|
166
|
+
comments.forEach(function (c) {
|
|
167
|
+
if (c.resolved && c.id !== focusedId) return;
|
|
168
|
+
var x = null, y = null;
|
|
169
|
+
var el = null;
|
|
170
|
+
if (c.selector) {
|
|
171
|
+
try { el = document.querySelector(c.selector); } catch (err) { el = null; }
|
|
172
|
+
}
|
|
173
|
+
if (el) {
|
|
174
|
+
var r = el.getBoundingClientRect();
|
|
175
|
+
if (r.width || r.height) {
|
|
176
|
+
x = r.left + (c.relX == null ? 0.5 : c.relX) * r.width;
|
|
177
|
+
y = r.top + (c.relY == null ? 0.5 : c.relY) * r.height;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (x == null && c.xPct != null) {
|
|
181
|
+
x = (c.xPct / 100) * window.innerWidth;
|
|
182
|
+
y = (c.yPct / 100) * window.innerHeight;
|
|
183
|
+
}
|
|
184
|
+
if (x == null) return;
|
|
185
|
+
var pin = document.createElement('div');
|
|
186
|
+
pin.className = '__ds-pin' + (c.resolved ? ' __ds-resolved' : '') + (c.id === focusedId ? ' __ds-focused' : '');
|
|
187
|
+
pin.style.left = x + 'px';
|
|
188
|
+
pin.style.top = y + 'px';
|
|
189
|
+
pin.innerHTML = '<span>' + c.n + '</span>';
|
|
190
|
+
pin.addEventListener('click', function (e) {
|
|
191
|
+
e.stopPropagation();
|
|
192
|
+
parent.postMessage({ ds: 'pin-click', id: c.id }, '*');
|
|
193
|
+
});
|
|
194
|
+
pinLayer.appendChild(pin);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
var repaintQueued = false;
|
|
199
|
+
function queueRepaint() {
|
|
200
|
+
if (repaintQueued) return;
|
|
201
|
+
repaintQueued = true;
|
|
202
|
+
requestAnimationFrame(function () { repaintQueued = false; renderPins(); });
|
|
203
|
+
}
|
|
204
|
+
window.addEventListener('scroll', queueRepaint, true);
|
|
205
|
+
window.addEventListener('resize', queueRepaint);
|
|
206
|
+
setInterval(queueRepaint, 900);
|
|
207
|
+
|
|
208
|
+
/* ---------- bridge ---------- */
|
|
209
|
+
|
|
210
|
+
window.addEventListener('message', function (e) {
|
|
211
|
+
var d = e.data || {};
|
|
212
|
+
if (d.ds === 'mode') {
|
|
213
|
+
commentMode = !!d.comment;
|
|
214
|
+
document.documentElement.classList.toggle('__ds-comment-cursor', commentMode);
|
|
215
|
+
if (!commentMode) hideHighlight();
|
|
216
|
+
} else if (d.ds === 'comments') {
|
|
217
|
+
comments = d.items || [];
|
|
218
|
+
focusedId = d.focusedId || null;
|
|
219
|
+
renderPins();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
function announce() {
|
|
224
|
+
parent.postMessage({ ds: 'ready', route: location.pathname }, '*');
|
|
225
|
+
}
|
|
226
|
+
announce();
|
|
227
|
+
// The parent resets its bridge flag on iframe load, which fires after the
|
|
228
|
+
// first announce. Announce again once the page is fully loaded.
|
|
229
|
+
window.addEventListener('load', announce);
|
|
230
|
+
window.addEventListener('popstate', announce);
|
|
231
|
+
var push = history.pushState;
|
|
232
|
+
history.pushState = function () { push.apply(this, arguments); announce(); };
|
|
233
|
+
})();
|
package/public/style.css
CHANGED
|
@@ -67,6 +67,29 @@ button { font: inherit; cursor: pointer; }
|
|
|
67
67
|
|
|
68
68
|
.repo-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
69
69
|
|
|
70
|
+
.rail-filter {
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
gap: 7px;
|
|
74
|
+
margin: 2px 12px 4px;
|
|
75
|
+
padding: 4px 9px;
|
|
76
|
+
border: 1px solid var(--border);
|
|
77
|
+
border-radius: var(--radius);
|
|
78
|
+
color: var(--text-3);
|
|
79
|
+
background: #fff;
|
|
80
|
+
}
|
|
81
|
+
.rail-filter:focus-within { border-color: #c9c9cf; color: var(--text-2); }
|
|
82
|
+
.rail-filter input {
|
|
83
|
+
border: 0;
|
|
84
|
+
outline: none;
|
|
85
|
+
background: transparent;
|
|
86
|
+
font: inherit;
|
|
87
|
+
font-size: 12px;
|
|
88
|
+
color: var(--text);
|
|
89
|
+
width: 100%;
|
|
90
|
+
}
|
|
91
|
+
.rail-filter input::placeholder { color: var(--text-3); }
|
|
92
|
+
|
|
70
93
|
#rail-sections { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
|
|
71
94
|
|
|
72
95
|
.rail-section-label {
|
|
@@ -370,6 +393,19 @@ button { font: inherit; cursor: pointer; }
|
|
|
370
393
|
.comment-text { font-size: 12.5px; color: var(--text); }
|
|
371
394
|
.comment-meta { font-size: 11px; color: var(--text-3); margin-top: 5px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
372
395
|
|
|
396
|
+
.comment-anchor {
|
|
397
|
+
margin-top: 5px;
|
|
398
|
+
font-size: 11px;
|
|
399
|
+
color: var(--text-2);
|
|
400
|
+
background: #f6f6f7;
|
|
401
|
+
border: 1px solid var(--border);
|
|
402
|
+
border-radius: 5px;
|
|
403
|
+
padding: 3px 7px;
|
|
404
|
+
overflow: hidden;
|
|
405
|
+
text-overflow: ellipsis;
|
|
406
|
+
white-space: nowrap;
|
|
407
|
+
}
|
|
408
|
+
|
|
373
409
|
.chip {
|
|
374
410
|
display: inline-flex;
|
|
375
411
|
align-items: center;
|
package/src/previews.js
CHANGED
|
@@ -4,6 +4,7 @@ import http from 'node:http';
|
|
|
4
4
|
import net from 'node:net';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { git, tryGit } from './git.js';
|
|
7
|
+
import { startInjectingProxy } from './proxy.js';
|
|
7
8
|
|
|
8
9
|
const URL_RE = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d+[^\s'"]*)/;
|
|
9
10
|
const START_TIMEOUT_MS = 90_000;
|
|
@@ -125,6 +126,25 @@ export class PreviewManager {
|
|
|
125
126
|
return this.start(key, { cwd: wt, commit, worktree: wt });
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
// Comments need to see inside the preview, so the dashboard never talks to
|
|
130
|
+
// the dev server directly. Each preview gets a proxy that injects the
|
|
131
|
+
// inspector script. If the proxy fails, fall back to the direct URL: the
|
|
132
|
+
// preview still works, only element pinning degrades.
|
|
133
|
+
attachProxy(rec, targetUrl) {
|
|
134
|
+
if (rec.targetUrl) return;
|
|
135
|
+
rec.targetUrl = targetUrl;
|
|
136
|
+
startInjectingProxy(targetUrl)
|
|
137
|
+
.then(({ server, url }) => {
|
|
138
|
+
rec.proxyServer = server;
|
|
139
|
+
rec.url = url;
|
|
140
|
+
rec.status = 'ready';
|
|
141
|
+
})
|
|
142
|
+
.catch(() => {
|
|
143
|
+
rec.url = targetUrl;
|
|
144
|
+
rec.status = 'ready';
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
128
148
|
record(key) {
|
|
129
149
|
let rec = this.previews.get(key);
|
|
130
150
|
if (!rec) {
|
|
@@ -138,6 +158,8 @@ export class PreviewManager {
|
|
|
138
158
|
const rec = this.record(key);
|
|
139
159
|
rec.status = 'starting';
|
|
140
160
|
rec.url = null;
|
|
161
|
+
rec.targetUrl = null;
|
|
162
|
+
if (rec.proxyServer) { try { rec.proxyServer.close(); } catch { /* gone */ } rec.proxyServer = null; }
|
|
141
163
|
rec.error = null;
|
|
142
164
|
rec.log = '';
|
|
143
165
|
rec.commit = commit;
|
|
@@ -156,8 +178,7 @@ export class PreviewManager {
|
|
|
156
178
|
const dir = path.resolve(cwd, cfg.dir || '.');
|
|
157
179
|
const server = http.createServer((req, res) => serveStatic(dir, req, res));
|
|
158
180
|
server.listen(0, '127.0.0.1', () => {
|
|
159
|
-
rec
|
|
160
|
-
rec.status = 'ready';
|
|
181
|
+
this.attachProxy(rec, `http://localhost:${server.address().port}/`);
|
|
161
182
|
});
|
|
162
183
|
rec.server = server;
|
|
163
184
|
return rec;
|
|
@@ -176,11 +197,11 @@ export class PreviewManager {
|
|
|
176
197
|
const onData = (buf) => {
|
|
177
198
|
const text = stripAnsi(buf.toString());
|
|
178
199
|
rec.log = (rec.log + text).slice(-8000);
|
|
179
|
-
if (!rec.
|
|
200
|
+
if (!rec.targetUrl) {
|
|
180
201
|
const m = text.match(URL_RE);
|
|
181
202
|
if (m) {
|
|
182
|
-
|
|
183
|
-
rec
|
|
203
|
+
const targetUrl = m[1].replace('0.0.0.0', 'localhost').replace('127.0.0.1', 'localhost');
|
|
204
|
+
this.attachProxy(rec, targetUrl);
|
|
184
205
|
}
|
|
185
206
|
}
|
|
186
207
|
};
|
|
@@ -214,6 +235,9 @@ export class PreviewManager {
|
|
|
214
235
|
if (rec.server) {
|
|
215
236
|
try { rec.server.close(); } catch { /* gone */ }
|
|
216
237
|
}
|
|
238
|
+
if (rec.proxyServer) {
|
|
239
|
+
try { rec.proxyServer.close(); } catch { /* gone */ }
|
|
240
|
+
}
|
|
217
241
|
}
|
|
218
242
|
}
|
|
219
243
|
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const INSPECT_PATH = '/__design-share__/inspect.js';
|
|
8
|
+
const INSPECT_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public', 'inspect.js');
|
|
9
|
+
const INJECT_TAG = `<script src="${INSPECT_PATH}"></script>`;
|
|
10
|
+
|
|
11
|
+
// Sits in front of a preview dev server on its own port. Mirrors every path
|
|
12
|
+
// 1:1 so absolute asset URLs keep working, injects the inspector script into
|
|
13
|
+
// HTML responses, and pipes websocket upgrades straight through so hot module
|
|
14
|
+
// reload keeps working.
|
|
15
|
+
export function startInjectingProxy(targetUrl) {
|
|
16
|
+
const target = new URL(targetUrl);
|
|
17
|
+
|
|
18
|
+
const server = http.createServer((req, res) => {
|
|
19
|
+
if (req.url === INSPECT_PATH) {
|
|
20
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
21
|
+
return res.end(fs.readFileSync(INSPECT_FILE));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const headers = { ...req.headers, host: target.host };
|
|
25
|
+
delete headers['accept-encoding']; // keep HTML plain so injection is trivial
|
|
26
|
+
|
|
27
|
+
const upstream = http.request({
|
|
28
|
+
hostname: target.hostname,
|
|
29
|
+
port: target.port,
|
|
30
|
+
path: req.url,
|
|
31
|
+
method: req.method,
|
|
32
|
+
headers,
|
|
33
|
+
}, (up) => {
|
|
34
|
+
const type = up.headers['content-type'] || '';
|
|
35
|
+
const outHeaders = { ...up.headers };
|
|
36
|
+
|
|
37
|
+
// Point same-host redirects back at the proxy
|
|
38
|
+
if (outHeaders.location) {
|
|
39
|
+
outHeaders.location = outHeaders.location.replace(target.host, `localhost:${server.address().port}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (type.includes('text/html')) {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
up.on('data', (c) => chunks.push(c));
|
|
45
|
+
up.on('end', () => {
|
|
46
|
+
let body = Buffer.concat(chunks).toString('utf8');
|
|
47
|
+
if (body.includes('</body>')) {
|
|
48
|
+
body = body.replace('</body>', `${INJECT_TAG}</body>`);
|
|
49
|
+
} else if (body.includes('</html>')) {
|
|
50
|
+
body = body.replace('</html>', `${INJECT_TAG}</html>`);
|
|
51
|
+
} else {
|
|
52
|
+
body += INJECT_TAG;
|
|
53
|
+
}
|
|
54
|
+
delete outHeaders['content-length'];
|
|
55
|
+
delete outHeaders['content-encoding'];
|
|
56
|
+
res.writeHead(up.statusCode, outHeaders);
|
|
57
|
+
res.end(body);
|
|
58
|
+
});
|
|
59
|
+
} else {
|
|
60
|
+
res.writeHead(up.statusCode, outHeaders);
|
|
61
|
+
up.pipe(res);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
upstream.on('error', () => {
|
|
66
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
67
|
+
res.end('preview process is not responding');
|
|
68
|
+
});
|
|
69
|
+
req.pipe(upstream);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Raw tcp passthrough for websockets (vite/next HMR)
|
|
73
|
+
server.on('upgrade', (req, socket, head) => {
|
|
74
|
+
const conn = net.connect(Number(target.port), target.hostname, () => {
|
|
75
|
+
const headerLines = Object.entries(req.headers)
|
|
76
|
+
.map(([k, v]) => `${k}: ${k === 'host' ? target.host : v}`)
|
|
77
|
+
.join('\r\n');
|
|
78
|
+
conn.write(`${req.method} ${req.url} HTTP/1.1\r\n${headerLines}\r\n\r\n`);
|
|
79
|
+
if (head && head.length) conn.write(head);
|
|
80
|
+
socket.pipe(conn);
|
|
81
|
+
conn.pipe(socket);
|
|
82
|
+
});
|
|
83
|
+
const drop = () => { socket.destroy(); conn.destroy(); };
|
|
84
|
+
conn.on('error', drop);
|
|
85
|
+
socket.on('error', drop);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
server.once('error', reject);
|
|
90
|
+
server.listen(0, '127.0.0.1', () => {
|
|
91
|
+
resolve({ server, url: `http://localhost:${server.address().port}/` });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
package/src/server.js
CHANGED
|
@@ -128,6 +128,10 @@ export class DesignShareServer {
|
|
|
128
128
|
route: body.route || '/',
|
|
129
129
|
xPct: body.xPct,
|
|
130
130
|
yPct: body.yPct,
|
|
131
|
+
selector: typeof body.selector === 'string' ? body.selector.slice(0, 500) : null,
|
|
132
|
+
elementLabel: typeof body.elementLabel === 'string' ? body.elementLabel.slice(0, 200) : null,
|
|
133
|
+
relX: typeof body.relX === 'number' ? body.relX : null,
|
|
134
|
+
relY: typeof body.relY === 'number' ? body.relY : null,
|
|
131
135
|
viewportLabel: body.viewportLabel || 'desktop',
|
|
132
136
|
viewportW: body.viewportW,
|
|
133
137
|
viewportH: body.viewportH,
|