product-planning(同步 26 改 65 新)
- 目录约定:docs/pm/<项目>/ ⇒ <工作区>/项目/<产品slug>/ 为产品根,下分 产品规划/ 与 产品原型/{designs,pages}
- ④段骨架六节 ⇒ 七节(新增「原型地址」;后两节改「页面导航」「原型状态取值」)
- ③段新增真机组件库;结案「源站只有暗色主题」,tokens.css(亮色)标为不可用于生产
- ③段改版规则反转为「一个产品只用一套原型、只留当前态」;②段新增 产品规划模板.md(表头唯一定义处)
- scripts:产品根定位重写(含 .registry/current-project 指针),新增 check_terms.py 与 selftest_product_root.py
session-mechanism(同步 3 改 2 新)
- references/06-技能优化规程.md 新增 §5「要求落在生成时看得见的面上」,总表 16 ⇒ 17 条;SKILL.md 同步
- scripts/collabctl.py 取回 _find_board() 候选定位(旧版写死 scripts/board.py,而看板已搬进 boards/会话看板/ ⇒ 旧版本就是坏的)
element-picker(新装)
- 按用户口径作「独立功能加入 product-planning」,非独立全局技能
- 修掉它自身三处路径漂移(技能根写法 ⇒ 同目录;README 两处旧落点;剪贴板示例文案与代码对齐)
工具归拢到技能级 assets/
- diagram-design / design-capture / element-picker / video-capture 从各段 assets/ 移到技能包 assets/
- 新增红线:本包有两处 assets/(技能级工具箱 vs 段内资产),引工具一律写「技能级 assets/<工具名>/」
- 路径口径由两类改三类(技能级工具箱 / 本段自带资产 / 同根技能)
不入库(磁盘保留)
- design-system-tiaoyue 整个目录(用户 2026-10-16 令):git rm -r --cached + .gitignore,理由=14 MB 且可 CDP 重采;
已在 ③段 SKILL.md 注明「取仓后这一层是空的属正常,用自带套需另取或重采」
- element-picker 的运行期存档 marks/(服务自己会建目录)
361 lines
14 KiB
JavaScript
361 lines
14 KiB
JavaScript
/**
|
|
* picker.js —— 页面元素拾取器 v3(确认式弹窗)
|
|
*
|
|
* 配合 tools/ui-picker/ui-picker-server.js 使用:
|
|
* 页面引入 <script src="http://127.0.0.1:8890/picker.js"></script>
|
|
* URL 带 ?pick 才激活(不带就完全不碰页面)。
|
|
*
|
|
* 操作:鼠标划过元素出蓝框 → 点一下 → 弹窗里直接给出该组件的
|
|
* 「选择器 / 地址 / 文本」,点「确认」即复制进剪贴板,粘到对话框里说明怎么改即可。
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
if (!new URLSearchParams(location.search).has('pick')) return;
|
|
|
|
var API = 'http://127.0.0.1:8890';
|
|
try {
|
|
if (document.currentScript && document.currentScript.src) {
|
|
API = new URL(document.currentScript.src).origin;
|
|
}
|
|
} catch (e) {}
|
|
|
|
/* ---------- 路径与上下文提取 ---------- */
|
|
|
|
/* 生成候选路径;forceNth = true 时每一级都带 :nth-of-type,等于完整树路径 */
|
|
function rawPath(el, forceNth) {
|
|
if (!el || el.nodeType !== 1) return '';
|
|
if (el.id && document.querySelectorAll('#' + CSS.escape(el.id)).length === 1) {
|
|
return '#' + CSS.escape(el.id);
|
|
}
|
|
var parts = [];
|
|
var node = el;
|
|
while (node && node.nodeType === 1 && node !== document.documentElement) {
|
|
var sel = node.nodeName.toLowerCase();
|
|
if (node.id) {
|
|
sel += '#' + CSS.escape(node.id);
|
|
parts.unshift(sel);
|
|
break;
|
|
}
|
|
var cls = Array.prototype.slice.call(node.classList).filter(function (c) {
|
|
return !/^(ng-|css-|sc-|is-|has-)/.test(c);
|
|
}).slice(0, 3);
|
|
if (cls.length) sel += '.' + cls.map(function (c) { return CSS.escape(c); }).join('.');
|
|
var parent = node.parentElement;
|
|
if (parent) {
|
|
var same = Array.prototype.filter.call(parent.children, function (c) {
|
|
return c.nodeName === node.nodeName;
|
|
});
|
|
if (forceNth || same.length > 1) sel += ':nth-of-type(' + (same.indexOf(node) + 1) + ')';
|
|
}
|
|
parts.unshift(sel);
|
|
node = parent;
|
|
if (parts.length >= 7) break;
|
|
}
|
|
return parts.join(' > ');
|
|
}
|
|
|
|
/* 校验:这条选择器在页面上是不是恰好只命中这一个元素 */
|
|
function matchesExactly(sel, el) {
|
|
if (!sel) return false;
|
|
try {
|
|
var list = document.querySelectorAll(sel);
|
|
return list.length === 1 && list[0] === el;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/* 先给最短路径;若不唯一,退到每一级都带下标的完整路径 */
|
|
function cssPath(el) {
|
|
var base = rawPath(el, false);
|
|
if (matchesExactly(base, el)) return base;
|
|
var full = rawPath(el, true);
|
|
if (matchesExactly(full, el)) return full;
|
|
return base;
|
|
}
|
|
|
|
function breadcrumb(el) {
|
|
var names = [];
|
|
var node = el;
|
|
while (node && node.nodeType === 1 && node !== document.body) {
|
|
var n = node.nodeName.toLowerCase();
|
|
if (node.id) n += '#' + node.id;
|
|
else if (node.classList.length) n += '.' + Array.prototype.slice.call(node.classList, 0, 2).join('.');
|
|
names.unshift(n);
|
|
node = node.parentElement;
|
|
}
|
|
return names.join(' > ');
|
|
}
|
|
|
|
var STYLE_KEYS = ['display', 'position', 'width', 'height', 'margin', 'padding',
|
|
'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'border', 'borderRadius',
|
|
'boxShadow', 'gap', 'flexDirection', 'justifyContent', 'alignItems', 'gridTemplateColumns', 'textAlign'];
|
|
|
|
function stylesOf(el) {
|
|
var cs = getComputedStyle(el);
|
|
var out = {};
|
|
STYLE_KEYS.forEach(function (k) { out[k] = cs[k]; });
|
|
return out;
|
|
}
|
|
|
|
function attrsOf(el) {
|
|
var out = {};
|
|
Array.prototype.forEach.call(el.attributes, function (a) {
|
|
if (a.name === 'style' || a.name === 'class' || a.name === 'id') return;
|
|
var v = a.value || '';
|
|
out[a.name] = v.length > 120 ? v.slice(0, 120) + '…' : v;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function textOf(el) {
|
|
return (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function collect(el, sel) {
|
|
var r = el.getBoundingClientRect();
|
|
return {
|
|
page: location.href,
|
|
pageTitle: (document.title || '').trim(),
|
|
note: '',
|
|
selector: sel,
|
|
breadcrumb: breadcrumb(el),
|
|
tag: el.tagName.toLowerCase(),
|
|
classes: Array.prototype.slice.call(el.classList),
|
|
text: textOf(el).slice(0, 200),
|
|
attrs: attrsOf(el),
|
|
rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
|
|
styles: stylesOf(el),
|
|
html: el.outerHTML.length > 1500 ? el.outerHTML.slice(0, 1500) + ' <!-- 截断 -->' : el.outerHTML
|
|
};
|
|
}
|
|
|
|
/* ---------- 剪贴板 ---------- */
|
|
|
|
function legacyCopy(text) {
|
|
try {
|
|
var ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.setAttribute('readonly', '');
|
|
ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0;';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
ta.setSelectionRange(0, ta.value.length);
|
|
var ok = document.execCommand('copy');
|
|
ta.remove();
|
|
return ok;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function copyText(text) {
|
|
try {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(function () {}, function () { legacyCopy(text); });
|
|
return true;
|
|
}
|
|
} catch (e) {}
|
|
return legacyCopy(text);
|
|
}
|
|
|
|
/* ---------- UI ---------- */
|
|
|
|
var host = document.createElement('div');
|
|
host.id = '__ui_picker_host';
|
|
host.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647;';
|
|
document.documentElement.appendChild(host);
|
|
var root = host.attachShadow({ mode: 'open' });
|
|
|
|
root.innerHTML = [
|
|
'<style>',
|
|
'.box{position:fixed;pointer-events:none;border:2px solid #2563eb;background:rgba(37,99,235,.10);border-radius:3px;}',
|
|
'.box.hit{border-color:#16a34a;background:rgba(22,163,74,.14);}',
|
|
'.tag{position:fixed;pointer-events:none;font:11px/1.6 ui-monospace,Consolas,monospace;background:#2563eb;color:#fff;padding:1px 6px;border-radius:3px;white-space:nowrap;max-width:70vw;overflow:hidden;text-overflow:ellipsis;}',
|
|
'.dock{position:fixed;right:16px;bottom:16px;pointer-events:auto;display:flex;align-items:center;gap:6px;',
|
|
' font:12px/1 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;background:#fff;color:#111827;',
|
|
' border:1px solid #d1d5db;border-radius:999px;padding:6px 8px 6px 10px;box-shadow:0 6px 20px rgba(0,0,0,.16);}',
|
|
'.dot{width:9px;height:9px;border-radius:50%;background:#9ca3af;flex:none;}',
|
|
'.dot.on{background:#16a34a;box-shadow:0 0 0 3px rgba(22,163,74,.18);}',
|
|
'.dock b{font-weight:600;}',
|
|
'.dock button{pointer-events:auto;font:inherit;border:1px solid #d1d5db;background:#f9fafb;color:#374151;',
|
|
' border-radius:6px;padding:3px 7px;cursor:pointer;}',
|
|
'.dock button:hover{background:#f3f4f6;}',
|
|
'.hint{color:#6b7280;}',
|
|
'.dlg{position:fixed;pointer-events:auto;width:430px;max-width:calc(100vw - 24px);background:#fff;color:#111827;',
|
|
' border:1px solid #d1d5db;border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.22);padding:12px;',
|
|
' font:12px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;}',
|
|
'.dlg .hd{font-weight:600;color:#2563eb;margin-bottom:8px;word-break:break-all;}',
|
|
'.dlg .kv{display:flex;gap:8px;margin-bottom:6px;align-items:flex-start;}',
|
|
'.dlg .kv label{flex:none;width:44px;color:#6b7280;}',
|
|
'.dlg .kv code{flex:1;font:12px/1.5 ui-monospace,Consolas,monospace;background:#f3f4f6;border-radius:4px;',
|
|
' padding:3px 6px;word-break:break-all;user-select:text;cursor:text;}',
|
|
'.dlg .ft{display:flex;justify-content:flex-end;gap:8px;margin-top:10px;}',
|
|
'.dlg .ft button{font:inherit;border-radius:6px;padding:5px 16px;cursor:pointer;border:1px solid #d1d5db;background:#f9fafb;color:#374151;}',
|
|
'.dlg .ft button:hover{background:#f3f4f6;}',
|
|
'.dlg .ft button.primary{background:#2563eb;border-color:#2563eb;color:#fff;}',
|
|
'.dlg .ft button.primary:hover{background:#1d4ed8;}',
|
|
'.toast{position:fixed;left:50%;transform:translateX(-50%);bottom:70px;max-width:80vw;pointer-events:none;',
|
|
' background:#111827;color:#fff;font:12px/1.6 ui-monospace,Consolas,"Microsoft YaHei",monospace;',
|
|
' padding:8px 14px;border-radius:8px;opacity:0;transition:opacity .18s;white-space:pre-wrap;word-break:break-all;}',
|
|
'.toast.show{opacity:1;}',
|
|
'</style>',
|
|
'<div class="box" hidden></div>',
|
|
'<div class="tag" hidden></div>',
|
|
'<div class="dock"><span class="dot"></span><b class="st">未开启</b>',
|
|
'<span class="hint">已复制 <b class="cnt">0</b></span>',
|
|
'<button class="toggle">开启</button></div>',
|
|
'<div class="toast"></div>'
|
|
].join('');
|
|
|
|
var box = root.querySelector('.box');
|
|
var tag = root.querySelector('.tag');
|
|
var dot = root.querySelector('.dot');
|
|
var stEl = root.querySelector('.st');
|
|
var cntEl = root.querySelector('.cnt');
|
|
var btnToggle = root.querySelector('.toggle');
|
|
var toast = root.querySelector('.toast');
|
|
var copied = 0;
|
|
var active = false;
|
|
var hovered = null;
|
|
var dialog = null;
|
|
|
|
function toastMsg(msg, ms) {
|
|
toast.textContent = msg;
|
|
toast.classList.add('show');
|
|
clearTimeout(toast.__t);
|
|
toast.__t = setTimeout(function () { toast.classList.remove('show'); }, ms || 2600);
|
|
}
|
|
|
|
function drawBox(el, hit) {
|
|
if (!el) { box.hidden = true; tag.hidden = true; return; }
|
|
var r = el.getBoundingClientRect();
|
|
box.style.left = r.left + 'px';
|
|
box.style.top = r.top + 'px';
|
|
box.style.width = r.width + 'px';
|
|
box.style.height = r.height + 'px';
|
|
box.classList.toggle('hit', !!hit);
|
|
box.hidden = false;
|
|
tag.textContent = cssPath(el);
|
|
tag.style.left = r.left + 'px';
|
|
tag.style.top = Math.max(0, r.top - 20) + 'px';
|
|
tag.hidden = false;
|
|
}
|
|
|
|
function isOurs(e) {
|
|
return e.composedPath && e.composedPath().indexOf(host) !== -1;
|
|
}
|
|
|
|
function closeDialog() {
|
|
if (dialog) { dialog.remove(); dialog = null; }
|
|
}
|
|
|
|
function openDialog(el) {
|
|
closeDialog();
|
|
|
|
var sel = cssPath(el);
|
|
var pageName = (document.title || '').trim() || location.hostname;
|
|
var txt = textOf(el).slice(0, 80);
|
|
|
|
var d = document.createElement('div');
|
|
d.className = 'dlg';
|
|
d.innerHTML =
|
|
'<div class="hd"></div>' +
|
|
'<div class="kv"><label>选择器</label><code class="sel"></code></div>' +
|
|
'<div class="kv"><label>网页</label><code class="url"></code></div>' +
|
|
(txt ? '<div class="kv"><label>文本</label><code class="txt"></code></div>' : '') +
|
|
'<div class="ft"><button class="cancel">取消</button><button class="primary ok">确认</button></div>';
|
|
|
|
d.querySelector('.hd').textContent = el.tagName.toLowerCase() +
|
|
(el.classList.length ? '.' + el.classList[0] : '');
|
|
d.querySelector('.sel').textContent = sel;
|
|
d.querySelector('.url').textContent = pageName;
|
|
if (txt) d.querySelector('.txt').textContent = txt;
|
|
|
|
root.appendChild(d);
|
|
dialog = d;
|
|
d.tabIndex = -1;
|
|
try { d.focus({ preventScroll: true }); } catch (e) {}
|
|
|
|
var r = el.getBoundingClientRect();
|
|
d.style.left = Math.min(Math.max(8, r.left), Math.max(8, innerWidth - 446)) + 'px';
|
|
d.style.top = (r.bottom + 12 + d.offsetHeight > innerHeight)
|
|
? Math.max(8, r.top - d.offsetHeight - 12) + 'px'
|
|
: r.bottom + 12 + 'px';
|
|
|
|
d.querySelector('.cancel').addEventListener('click', closeDialog);
|
|
d.querySelector('.ok').addEventListener('click', function () {
|
|
var payload = '选择器:' + sel + '\n网页:' + pageName;
|
|
var ok = copyText(payload);
|
|
copied += 1;
|
|
cntEl.textContent = copied;
|
|
drawBox(el, true);
|
|
toastMsg((ok ? '已复制:\n' : '剪贴板被拒,内容如下:\n') + payload);
|
|
closeDialog();
|
|
try {
|
|
fetch(API + '/mark', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(collect(el, sel))
|
|
}).catch(function () {});
|
|
} catch (err) {}
|
|
});
|
|
|
|
d.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Escape') closeDialog();
|
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) d.querySelector('.ok').click();
|
|
});
|
|
}
|
|
|
|
function onMove(e) {
|
|
if (!active || dialog || isOurs(e)) return;
|
|
hovered = e.target;
|
|
drawBox(hovered, false);
|
|
}
|
|
|
|
function onDown(e) {
|
|
if (!active || isOurs(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
|
|
function onClick(e) {
|
|
if (!active || isOurs(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openDialog(e.target);
|
|
}
|
|
|
|
function setActive(v) {
|
|
active = v;
|
|
dot.classList.toggle('on', v);
|
|
stEl.textContent = v ? '拾取中' : '未开启';
|
|
btnToggle.textContent = v ? '关闭' : '开启';
|
|
document.documentElement.style.cursor = v ? 'crosshair' : '';
|
|
if (!v) { drawBox(null); hovered = null; closeDialog(); }
|
|
}
|
|
|
|
btnToggle.addEventListener('click', function () { setActive(!active); });
|
|
|
|
document.addEventListener('mousemove', onMove, true);
|
|
document.addEventListener('mousedown', onDown, true);
|
|
document.addEventListener('click', onClick, true);
|
|
window.addEventListener('keydown', function (e) {
|
|
// Esc 优先:有弹窗就关弹窗,没弹窗就退出拾取模式 —— 焦点在哪儿都生效
|
|
if (e.key === 'Escape') {
|
|
if (dialog) closeDialog();
|
|
else if (active) setActive(false);
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (isOurs(e)) return;
|
|
var t = e.target;
|
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
|
if (e.key === 'z' || e.key === 'Z') { e.preventDefault(); setActive(!active); }
|
|
}, true);
|
|
window.addEventListener('scroll', function () { if (hovered) drawBox(hovered); }, true);
|
|
|
|
setActive(true);
|
|
})();
|