mirror of
https://github.com/potato47/ccc-devtools.git
synced 2026-04-06 13:22:32 +00:00
feat: 整体重构
This commit is contained in:
208
packages/cccdev-template-3x/src/components/App.tsx
Normal file
208
packages/cccdev-template-3x/src/components/App.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { useComputed, useSignal } from '@preact/signals';
|
||||
import { devtoolsOpen, profilerOpen } from '../store';
|
||||
import { TreePanel } from './TreePanel';
|
||||
import { PropPanel } from './PropPanel';
|
||||
import { ProfilerPanel } from './ProfilerPanel';
|
||||
|
||||
const MIN_WIDTH = 240;
|
||||
const MAX_WIDTH = 600;
|
||||
const STORAGE_KEY = 'cc_devtools_width';
|
||||
|
||||
function getSavedWidth(): number {
|
||||
const v = parseInt(localStorage.getItem(STORAGE_KEY) ?? '', 10);
|
||||
return isNaN(v) ? 320 : Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, v));
|
||||
}
|
||||
|
||||
function getToolbarHeight(): number {
|
||||
const toolbar = document.querySelector('.toolbar') as HTMLElement | null;
|
||||
return toolbar ? toolbar.getBoundingClientRect().height : 42;
|
||||
}
|
||||
|
||||
/** 将游戏内容区向左推开,避免面板遮挡 */
|
||||
function pushGameCanvas(width: number) {
|
||||
const content = document.getElementById('content');
|
||||
if (content) content.style.paddingRight = `${width + 16}px`; // 面板宽 + 两侧间距
|
||||
}
|
||||
|
||||
function restoreGameCanvas() {
|
||||
const content = document.getElementById('content');
|
||||
if (content) content.style.paddingRight = '';
|
||||
}
|
||||
|
||||
function ToggleButton({ toolbarH }: { toolbarH: number }) {
|
||||
const active = devtoolsOpen.value;
|
||||
return (
|
||||
<button
|
||||
class={`ccdev-toggle${active ? ' active' : ''}`}
|
||||
style={{ top: `${Math.round(toolbarH / 2)}px` }}
|
||||
onClick={() => {
|
||||
devtoolsOpen.value = !devtoolsOpen.value;
|
||||
}}
|
||||
title={active ? '关闭 DevTools' : '打开 DevTools'}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M8 2l1.88 1.88" />
|
||||
<path d="M14.12 3.88L16 2" />
|
||||
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
|
||||
<path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" />
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M6.53 9C4.6 8.8 3 7.1 3 5" />
|
||||
<path d="M6 13H2" />
|
||||
<path d="M3 21c0-2.1 1.7-3.9 3.8-4" />
|
||||
<path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" />
|
||||
<path d="M22 13h-4" />
|
||||
<path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const open = useComputed(() => devtoolsOpen.value);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarH = useSignal(getToolbarHeight());
|
||||
|
||||
// 向外暴露 toggle,供 toolbar 按钮调用
|
||||
(window as any).__ccDevToolsToggle = () => {
|
||||
devtoolsOpen.value = !devtoolsOpen.value;
|
||||
};
|
||||
(window as any).__ccProfilerToggle = () => {
|
||||
profilerOpen.value = !profilerOpen.value;
|
||||
};
|
||||
|
||||
// 持续监听 toolbar 实际高度,同步到 signal
|
||||
useEffect(() => {
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
function attach() {
|
||||
const toolbar = document.querySelector('.toolbar') as HTMLElement | null;
|
||||
if (!toolbar) return false;
|
||||
// 用 getBoundingClientRect 拿含 border 的完整高度
|
||||
toolbarH.value = Math.round(toolbar.getBoundingClientRect().bottom);
|
||||
ro = new ResizeObserver(() => {
|
||||
toolbarH.value = Math.round(toolbar.getBoundingClientRect().bottom);
|
||||
});
|
||||
ro.observe(toolbar);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!attach()) {
|
||||
// toolbar 尚未注入(由 cocosToolBar include 动态插入),轮询等待
|
||||
const id = setInterval(() => {
|
||||
if (attach()) clearInterval(id);
|
||||
}, 100);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
|
||||
return () => ro?.disconnect();
|
||||
}, []);
|
||||
|
||||
// 面板展开/收起时同步推开画布
|
||||
useEffect(() => {
|
||||
if (open.value) {
|
||||
const w = getSavedWidth();
|
||||
document.documentElement.style.setProperty('--devtools-width', `${w}px`);
|
||||
pushGameCanvas(w);
|
||||
} else {
|
||||
restoreGameCanvas();
|
||||
}
|
||||
return () => {
|
||||
restoreGameCanvas();
|
||||
};
|
||||
}, [open.value]);
|
||||
|
||||
// 左侧拖拽调整宽度
|
||||
useEffect(() => {
|
||||
const handle = handleRef.current;
|
||||
const panel = panelRef.current;
|
||||
if (!handle || !panel) return;
|
||||
|
||||
let startX = 0;
|
||||
let startW = 0;
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const delta = startX - e.clientX; // 向左拖 = 变宽
|
||||
const newW = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startW + delta));
|
||||
document.documentElement.style.setProperty('--devtools-width', `${newW}px`);
|
||||
pushGameCanvas(newW);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
if (!handle) return;
|
||||
handle.classList.remove('dragging');
|
||||
const content = document.getElementById('content');
|
||||
if (content) content.style.pointerEvents = '';
|
||||
const w = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--devtools-width'),
|
||||
10,
|
||||
);
|
||||
localStorage.setItem(STORAGE_KEY, String(w));
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (!handle || !panel) return;
|
||||
e.preventDefault();
|
||||
startX = e.clientX;
|
||||
startW = panel.getBoundingClientRect().width;
|
||||
handle.classList.add('dragging');
|
||||
const content = document.getElementById('content');
|
||||
if (content) content.style.pointerEvents = 'none';
|
||||
document.body.style.cursor = 'ew-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
handle.addEventListener('mousedown', onMouseDown);
|
||||
return () => handle.removeEventListener('mousedown', onMouseDown);
|
||||
}, [open.value]);
|
||||
|
||||
const GAP = 8; // 上下各留 8px 空隙
|
||||
const top = `${toolbarH.value + GAP}px`;
|
||||
const height = `calc(100vh - ${toolbarH.value + GAP * 2}px)`;
|
||||
|
||||
if (!open.value)
|
||||
return (
|
||||
<>
|
||||
<ToggleButton toolbarH={toolbarH.value} />
|
||||
<ProfilerPanel />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToggleButton toolbarH={toolbarH.value} />
|
||||
<div id="cc-devtools" ref={panelRef} style={{ top, height }}>
|
||||
<div id="cc-devtools-resize" ref={handleRef} title="拖拽调整面板宽度" />
|
||||
<a
|
||||
class="ccdev-github"
|
||||
href="https://github.com/potato47/ccc-devtools"
|
||||
target="_blank"
|
||||
title="GitHub"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
</a>
|
||||
<TreePanel />
|
||||
<PropPanel />
|
||||
</div>
|
||||
<ProfilerPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { PropItem } from './PropItem';
|
||||
import { getComponentViewModel } from '../models/ComponentModels';
|
||||
import { outputToConsole } from '../engine';
|
||||
|
||||
interface ComponentPanelProps {
|
||||
name: string;
|
||||
component: any;
|
||||
updateKey: number;
|
||||
}
|
||||
|
||||
export function ComponentPanel({ name, component, updateKey: _updateKey }: ComponentPanelProps) {
|
||||
const collapsed = useSignal(false);
|
||||
const model = getComponentViewModel(name, () => component);
|
||||
|
||||
return (
|
||||
<div class="comp-panel">
|
||||
<div
|
||||
class="comp-header"
|
||||
onClick={() => {
|
||||
collapsed.value = !collapsed.value;
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="comp-enabled"
|
||||
checked={component?.enabled}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
if (component) component.enabled = (e.target as HTMLInputElement).checked;
|
||||
}}
|
||||
/>
|
||||
<span class="comp-name">{name}</span>
|
||||
<span class="comp-arrow">{collapsed.value ? '›' : '⌄'}</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="输出到控制台"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
outputToConsole(component);
|
||||
}}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed.value && model && (
|
||||
<div class="comp-props">
|
||||
{model.props.map((prop) => (
|
||||
<PropItem key={prop.key} model={model} propName={prop.name} propKey={prop.key} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed.value && !model && <div class="comp-empty">(无可编辑属性)</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
packages/cccdev-template-3x/src/components/ProfilerPanel.tsx
Normal file
120
packages/cccdev-template-3x/src/components/ProfilerPanel.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { profilerOpen } from '../store';
|
||||
import { cc } from '../engine';
|
||||
|
||||
interface StatItem {
|
||||
key: string;
|
||||
desc: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const STAT_KEYS = [
|
||||
'fps',
|
||||
'draws',
|
||||
'frame',
|
||||
'instances',
|
||||
'tricount',
|
||||
'logic',
|
||||
'physics',
|
||||
'render',
|
||||
'textureMemory',
|
||||
'bufferMemory',
|
||||
];
|
||||
|
||||
export function ProfilerPanel() {
|
||||
const items = useSignal<StatItem[]>(STAT_KEYS.map((key) => ({ key, desc: key, value: '—' })));
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const posX = useSignal(window.innerWidth - 260);
|
||||
const posY = useSignal(60);
|
||||
|
||||
// 轮询 cc.profiler.stats
|
||||
useEffect(() => {
|
||||
if (!profilerOpen.value) return;
|
||||
|
||||
function refresh() {
|
||||
const c = cc();
|
||||
if (!c?.profiler?.stats) return;
|
||||
const stats = c.profiler.stats;
|
||||
items.value = STAT_KEYS.map((key) => {
|
||||
const data = stats[key];
|
||||
if (!data) return { key, desc: key, value: '—' };
|
||||
const val = data.isInteger
|
||||
? String(data.counter._value | 0)
|
||||
: data.counter._value.toFixed(2);
|
||||
return { key, desc: data.desc ?? key, value: val };
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
const id = setInterval(refresh, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [profilerOpen.value]);
|
||||
|
||||
// 原生拖拽
|
||||
useEffect(() => {
|
||||
const header = panelRef.current?.querySelector('.profiler-drag') as HTMLElement;
|
||||
if (!header) return;
|
||||
|
||||
let startX = 0,
|
||||
startY = 0,
|
||||
startPX = 0,
|
||||
startPY = 0;
|
||||
let dragging = false;
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!dragging) return;
|
||||
posX.value = startPX + (e.clientX - startX);
|
||||
posY.value = startPY + (e.clientY - startY);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
dragging = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
dragging = true;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
startPX = posX.value;
|
||||
startPY = posY.value;
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
header.addEventListener('mousedown', onMouseDown);
|
||||
return () => header.removeEventListener('mousedown', onMouseDown);
|
||||
}, [profilerOpen.value]);
|
||||
|
||||
if (!profilerOpen.value) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
class="profiler-float"
|
||||
style={{ left: `${posX.value}px`, top: `${posY.value}px` }}
|
||||
>
|
||||
<div class="profiler-drag">
|
||||
<span>Profiler</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
onClick={() => {
|
||||
profilerOpen.value = false;
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="profiler-body">
|
||||
{items.value.map((item) => (
|
||||
<div class="profiler-row" key={item.key}>
|
||||
<span class="profiler-desc">{item.desc}</span>
|
||||
<span class="profiler-val">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
packages/cccdev-template-3x/src/components/PropItem.tsx
Normal file
120
packages/cccdev-template-3x/src/components/PropItem.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useRef, useEffect } from 'preact/hooks';
|
||||
import { cc } from '../engine';
|
||||
|
||||
interface PropItemProps {
|
||||
model: any;
|
||||
propName: string;
|
||||
propKey: string;
|
||||
}
|
||||
|
||||
function getPropType(value: any): string {
|
||||
if (value === null || value === undefined) return 'unknown';
|
||||
if (typeof value === 'object' && value.__classname__) return value.__classname__;
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function colorToHex(color: any): string {
|
||||
const hex = color.toHEX() as string;
|
||||
return `#${hex}`;
|
||||
}
|
||||
|
||||
function hexToColor(hex: string): any {
|
||||
return new (cc().Color)().fromHEX(hex);
|
||||
}
|
||||
|
||||
function formatNum(v: number): string {
|
||||
return Number.isInteger(v) ? String(v) : parseFloat(v.toFixed(3)).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 数字输入框:非受控 + onInput 实时写入。
|
||||
* 用 ref 在外部 tick 变化时手动同步显示值(仅在未聚焦时同步,避免打断输入)。
|
||||
*/
|
||||
function NumberInput({ model, propKey }: { model: any; propKey: string }) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 同步外部值到输入框(未聚焦时才更新,避免覆盖正在输入的内容)
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || document.activeElement === el) return;
|
||||
const external = model[propKey];
|
||||
if (parseFloat(el.value) !== external) {
|
||||
el.value = formatNum(external);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="number"
|
||||
class="prop-input"
|
||||
defaultValue={formatNum(model[propKey])}
|
||||
step="0.1"
|
||||
onInput={(e) => {
|
||||
const v = parseFloat((e.target as HTMLInputElement).value);
|
||||
if (!isNaN(v)) model[propKey] = v;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StringInput({ model, propKey }: { model: any; propKey: string }) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || document.activeElement === el) return;
|
||||
const external = String(model[propKey] ?? '');
|
||||
if (el.value !== external) el.value = external;
|
||||
});
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
class="prop-input"
|
||||
defaultValue={model[propKey]}
|
||||
onInput={(e) => {
|
||||
model[propKey] = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropItem({ model, propName, propKey }: PropItemProps) {
|
||||
const value = model[propKey];
|
||||
const type = getPropType(value);
|
||||
|
||||
return (
|
||||
<div class="prop-row">
|
||||
<span class="prop-name">{propName}</span>
|
||||
<div class="prop-value">
|
||||
{type === 'number' && <NumberInput model={model} propKey={propKey} />}
|
||||
{type === 'string' && <StringInput model={model} propKey={propKey} />}
|
||||
{type === 'boolean' && (
|
||||
<input
|
||||
type="checkbox"
|
||||
class="prop-checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => {
|
||||
model[propKey] = (e.target as HTMLInputElement).checked;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type === 'cc.Color' && (
|
||||
<input
|
||||
type="color"
|
||||
class="prop-color"
|
||||
value={colorToHex(value)}
|
||||
onChange={(e) => {
|
||||
model[propKey] = hexToColor((e.target as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!['number', 'string', 'boolean', 'cc.Color'].includes(type) && (
|
||||
<span class="prop-unknown">{String(value)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
packages/cccdev-template-3x/src/components/PropPanel.tsx
Normal file
64
packages/cccdev-template-3x/src/components/PropPanel.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useComputed } from '@preact/signals';
|
||||
import { selectedNode, updateTick } from '../store';
|
||||
import { isValid, getComponents, outputToConsole, drawNodeRect } from '../engine';
|
||||
import { NodeModel } from '../models/NodeModel';
|
||||
import { PropItem } from './PropItem';
|
||||
import { ComponentPanel } from './ComponentPanel';
|
||||
|
||||
export function PropPanel() {
|
||||
const node = useComputed(() => selectedNode.value);
|
||||
const tick = useComputed(() => updateTick.value);
|
||||
|
||||
if (!node.value || !isValid(node.value)) {
|
||||
return (
|
||||
<div class="prop-panel">
|
||||
<div class="panel-header">属性</div>
|
||||
<div class="prop-empty">未选中节点</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ccNode = node.value;
|
||||
const components = getComponents(ccNode);
|
||||
|
||||
return (
|
||||
<div class="prop-panel">
|
||||
<div class="panel-header">属性</div>
|
||||
<div class="prop-scroll">
|
||||
{/* 节点基础属性 */}
|
||||
<div class="comp-panel">
|
||||
<div class="comp-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="comp-enabled"
|
||||
checked={ccNode.active}
|
||||
onChange={(e) => {
|
||||
ccNode.active = (e.target as HTMLInputElement).checked;
|
||||
}}
|
||||
/>
|
||||
<span class="comp-name">Node</span>
|
||||
<div style="flex:1" />
|
||||
<button class="icon-btn" title="高亮节点" onClick={() => drawNodeRect(ccNode)}>
|
||||
⊡
|
||||
</button>
|
||||
<button class="icon-btn" title="输出到控制台" onClick={() => outputToConsole(ccNode)}>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div class="comp-props">
|
||||
{NodeModel.props.map((prop) => (
|
||||
<PropItem key={prop.key} model={NodeModel} propName={prop.name} propKey={prop.key} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
{/* 组件列表 */}
|
||||
{components.map(({ name, target }) => (
|
||||
<ComponentPanel key={name} name={name} component={target} updateKey={tick.value} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
packages/cccdev-template-3x/src/components/TreePanel.tsx
Normal file
275
packages/cccdev-template-3x/src/components/TreePanel.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useCallback, useRef } from 'preact/hooks';
|
||||
import { useSignal, useComputed } from '@preact/signals';
|
||||
import {
|
||||
treeData,
|
||||
expandedUuids,
|
||||
selectedNode,
|
||||
searchQuery,
|
||||
updateTick,
|
||||
type TreeNode,
|
||||
} from '../store';
|
||||
import { isReady, getScene, resolveNodeByPath } from '../engine';
|
||||
|
||||
// ── 构建树数据 ─────────────────────────────────────────────
|
||||
function buildTree(children: any[], path: string[]): TreeNode[] {
|
||||
const result: TreeNode[] = [];
|
||||
for (const ccNode of children) {
|
||||
const childPath = [...path, ccNode.uuid];
|
||||
const node: TreeNode = {
|
||||
uuid: ccNode.uuid,
|
||||
name: ccNode.name,
|
||||
active: ccNode.activeInHierarchy,
|
||||
children: ccNode.children?.length > 0 ? buildTree(ccNode.children, childPath) : [],
|
||||
path: childPath,
|
||||
};
|
||||
result.push(node);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 搜索:收集所有匹配节点(扁平列表)─────────────────────
|
||||
interface FlatMatch {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function collectMatches(nodes: TreeNode[], query: string, depth = 0): FlatMatch[] {
|
||||
const q = query.toLowerCase();
|
||||
const result: FlatMatch[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.name.toLowerCase().includes(q)) {
|
||||
result.push({ node, depth });
|
||||
}
|
||||
result.push(...collectMatches(node.children, query, depth + 1));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 搜索时自动展开匹配节点的所有祖先 ─────────────────────
|
||||
function expandAncestors(nodes: TreeNode[], query: string): Set<string> {
|
||||
const q = query.toLowerCase();
|
||||
const toExpand = new Set<string>();
|
||||
|
||||
function walk(node: TreeNode, ancestors: string[]): boolean {
|
||||
const matched = node.name.toLowerCase().includes(q);
|
||||
let childMatched = false;
|
||||
for (const child of node.children) {
|
||||
if (walk(child, [...ancestors, node.uuid])) childMatched = true;
|
||||
}
|
||||
if (matched || childMatched) {
|
||||
for (const uuid of ancestors) toExpand.add(uuid);
|
||||
}
|
||||
return matched || childMatched;
|
||||
}
|
||||
|
||||
for (const node of nodes) walk(node, []);
|
||||
return toExpand;
|
||||
}
|
||||
|
||||
// ── 高亮关键词 ────────────────────────────────────────────
|
||||
function HighlightText({ text, query }: { text: string; query: string }) {
|
||||
if (!query) return <span class="tree-label">{text}</span>;
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||||
if (idx === -1) return <span class="tree-label">{text}</span>;
|
||||
return (
|
||||
<span class="tree-label">
|
||||
{text.slice(0, idx)}
|
||||
<mark class="tree-highlight">{text.slice(idx, idx + query.length)}</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 搜索结果行 ────────────────────────────────────────────
|
||||
function SearchResultItem({ node, query }: { node: TreeNode; query: string }) {
|
||||
const isSelected = useComputed(() => selectedNode.value?.uuid === node.uuid);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const ccNode = resolveNodeByPath(node.path);
|
||||
selectedNode.value = ccNode ?? null;
|
||||
}, [node.path]);
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`tree-row${isSelected.value ? ' selected' : ''}${!node.active ? ' inactive' : ''}`}
|
||||
style={{ paddingLeft: '8px' }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<HighlightText text={node.name} query={query} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 单个树节点组件 ─────────────────────────────────────────
|
||||
interface TreeNodeItemProps {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function TreeNodeItem({ node, depth }: TreeNodeItemProps) {
|
||||
const expanded = useComputed(() => expandedUuids.value.has(node.uuid));
|
||||
const isSelected = useComputed(() => selectedNode.value?.uuid === node.uuid);
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const ccNode = resolveNodeByPath(node.path);
|
||||
selectedNode.value = ccNode ?? null;
|
||||
},
|
||||
[node.path],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const next = new Set(expandedUuids.value);
|
||||
if (next.has(node.uuid)) {
|
||||
next.delete(node.uuid);
|
||||
} else {
|
||||
next.add(node.uuid);
|
||||
}
|
||||
expandedUuids.value = next;
|
||||
},
|
||||
[node.uuid],
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="tree-node">
|
||||
<div
|
||||
class={`tree-row${isSelected.value ? ' selected' : ''}${!node.active ? ' inactive' : ''}`}
|
||||
style={{ paddingLeft: `${depth * 14 + 6}px` }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span
|
||||
class={`tree-arrow${hasChildren ? '' : ' invisible'}${expanded.value ? ' expanded' : ''}`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
›
|
||||
</span>
|
||||
<span class="tree-label">{node.name}</span>
|
||||
</div>
|
||||
{hasChildren && expanded.value && (
|
||||
<div class="tree-children">
|
||||
{node.children.map((child) => (
|
||||
<TreeNodeItem key={child.uuid} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── TreePanel 主组件 ───────────────────────────────────────
|
||||
export function TreePanel() {
|
||||
const initialized = useSignal(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const query = useComputed(() => searchQuery.value.trim());
|
||||
|
||||
// 搜索结果(扁平列表)
|
||||
const searchResults = useComputed(() => {
|
||||
if (!query.value) return null;
|
||||
return collectMatches(treeData.value, query.value);
|
||||
});
|
||||
|
||||
// 搜索关键词变化时,自动展开匹配节点的祖先
|
||||
useEffect(() => {
|
||||
if (!query.value) return;
|
||||
const ancestors = expandAncestors(treeData.value, query.value);
|
||||
if (ancestors.size === 0) return;
|
||||
expandedUuids.value = new Set([...expandedUuids.value, ...ancestors]);
|
||||
}, [query.value]);
|
||||
|
||||
useEffect(() => {
|
||||
let rafId: number;
|
||||
let started = false;
|
||||
|
||||
function refreshTree() {
|
||||
if (isReady()) {
|
||||
if (!started) {
|
||||
started = true;
|
||||
initialized.value = true;
|
||||
}
|
||||
treeData.value = buildTree(getScene().children, []);
|
||||
updateTick.value = -updateTick.value;
|
||||
}
|
||||
rafId = requestAnimationFrame(refreshTree);
|
||||
}
|
||||
|
||||
const pollId = setInterval(() => {
|
||||
if (isReady()) {
|
||||
clearInterval(pollId);
|
||||
rafId = requestAnimationFrame(refreshTree);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearInterval(pollId);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSearchInput = useCallback((e: Event) => {
|
||||
searchQuery.value = (e.target as HTMLInputElement).value;
|
||||
}, []);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
searchQuery.value = '';
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Esc 清空搜索
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
searchQuery.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div class="tree-panel">
|
||||
<div class="panel-header">节点树</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div class="tree-search-bar">
|
||||
<span class="tree-search-icon">⌕</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
class="tree-search-input"
|
||||
type="text"
|
||||
placeholder="搜索节点…"
|
||||
value={searchQuery.value}
|
||||
onInput={handleSearchInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{query.value && (
|
||||
<button class="tree-search-clear" onClick={handleClear} title="清空">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="tree-scroll">
|
||||
{!initialized.value ? (
|
||||
<div class="tree-empty">等待引擎初始化…</div>
|
||||
) : searchResults.value !== null ? (
|
||||
// 搜索模式:扁平结果列表
|
||||
searchResults.value.length === 0 ? (
|
||||
<div class="tree-empty">未找到匹配节点</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="tree-search-count">{searchResults.value.length} 个结果</div>
|
||||
{searchResults.value.map(({ node }) => (
|
||||
<SearchResultItem key={node.uuid} node={node} query={query.value} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
) : treeData.value.length === 0 ? (
|
||||
<div class="tree-empty">场景为空</div>
|
||||
) : (
|
||||
// 正常树模式
|
||||
treeData.value.map((node) => <TreeNodeItem key={node.uuid} node={node} depth={0} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
packages/cccdev-template-3x/src/engine.ts
Normal file
108
packages/cccdev-template-3x/src/engine.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/** 获取 Cocos 引擎全局对象 */
|
||||
export const cc = (): any => (window as any)['cc'];
|
||||
|
||||
export const getScene = (): any => cc()?.director?.getScene();
|
||||
|
||||
export const isReady = (): boolean => !!cc() && !!getScene();
|
||||
|
||||
export const isValid = (node: any): boolean => !!node && cc()?.isValid(node);
|
||||
|
||||
export const getComponents = (ccNode: any): Array<{ name: string; target: any }> =>
|
||||
(ccNode?.components ?? []).map((c: any) => ({ name: c.__classname__ as string, target: c }));
|
||||
|
||||
export const getSceneChildren = (): any[] => getScene()?.children ?? [];
|
||||
|
||||
export const getChildByUuid = (node: any, uuid: string): any => node?.getChildByUuid(uuid) ?? null;
|
||||
|
||||
/** 沿 uuid 路径从场景根查找节点 */
|
||||
export const resolveNodeByPath = (path: string[]): any => {
|
||||
let node: any = getScene();
|
||||
for (const uuid of path) {
|
||||
node = getChildByUuid(node, uuid);
|
||||
if (!node) return null;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
/** 将节点/组件输出到 window.temp1、temp2... 方便控制台操作 */
|
||||
export const outputToConsole = (target: any): void => {
|
||||
let i = 1;
|
||||
while ((window as any)['temp' + i] !== undefined) i++;
|
||||
(window as any)['temp' + i] = target;
|
||||
console.log('temp' + i, target);
|
||||
};
|
||||
|
||||
/** 在场景中高亮绘制节点包围盒,2s 后自动销毁 */
|
||||
export const drawNodeRect = (target: any): void => {
|
||||
const c = cc();
|
||||
if (!c) return;
|
||||
let rect: any;
|
||||
const transform = target.getComponent(c.UITransformComponent);
|
||||
if (transform) {
|
||||
rect = getSelfBoundingBoxToWorld(transform, c);
|
||||
} else {
|
||||
const worldPos = c.v3();
|
||||
target.getWorldPosition(worldPos);
|
||||
rect = c.rect(worldPos.x, worldPos.y, 0, 0);
|
||||
}
|
||||
const canvasNode = new c.Node('__DevTools_Highlight__');
|
||||
const scene = getScene();
|
||||
scene.addChild(canvasNode);
|
||||
canvasNode.addComponent(c.Canvas);
|
||||
const bgNode = new c.Node();
|
||||
const graphics = bgNode.addComponent(c.GraphicsComponent);
|
||||
const bgTransform = bgNode.addComponent(c.UITransformComponent);
|
||||
canvasNode.addChild(bgNode);
|
||||
const centerPos = c.v3(rect.center.x, rect.center.y, 0);
|
||||
const localPos = c.v3();
|
||||
canvasNode.getComponent(c.UITransformComponent).convertToNodeSpaceAR(centerPos, localPos);
|
||||
bgNode.setPosition(localPos);
|
||||
bgNode.layer = target.layer;
|
||||
const isZeroSize = rect.width === 0 || rect.height === 0;
|
||||
if (isZeroSize) {
|
||||
graphics.circle(0, 0, 100);
|
||||
graphics.fillColor = c.Color.GREEN;
|
||||
graphics.fill();
|
||||
} else {
|
||||
bgTransform.width = rect.width;
|
||||
bgTransform.height = rect.height;
|
||||
graphics.rect(
|
||||
-bgTransform.width / 2,
|
||||
-bgTransform.height / 2,
|
||||
bgTransform.width,
|
||||
bgTransform.height,
|
||||
);
|
||||
graphics.fillColor = new c.Color().fromHEX('#E91E6390');
|
||||
graphics.fill();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (c.isValid(canvasNode)) canvasNode.destroy();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
function getSelfBoundingBoxToWorld(transform: any, c: any): any {
|
||||
const _worldMatrix = c.mat4();
|
||||
if (transform.node.parent) {
|
||||
transform.node.parent.getWorldMatrix(_worldMatrix);
|
||||
const parentMat = _worldMatrix;
|
||||
const _matrix = c.mat4();
|
||||
c.Mat4.fromRTS(
|
||||
_matrix,
|
||||
transform.node.getRotation(),
|
||||
transform.node.getPosition(),
|
||||
transform.node.getScale(),
|
||||
);
|
||||
const width = transform._contentSize.width;
|
||||
const height = transform._contentSize.height;
|
||||
const rect = c.rect(
|
||||
-transform._anchorPoint.x * width,
|
||||
-transform._anchorPoint.y * height,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
c.Mat4.multiply(_worldMatrix, parentMat, _matrix);
|
||||
rect.transformMat4(_worldMatrix);
|
||||
return rect;
|
||||
}
|
||||
return transform.getBoundingBox();
|
||||
}
|
||||
17
packages/cccdev-template-3x/src/main.tsx
Normal file
17
packages/cccdev-template-3x/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { render } from 'preact';
|
||||
import { App } from './components/App';
|
||||
import './style.css';
|
||||
|
||||
// 等待 DOM 就绪后挂载
|
||||
function mount() {
|
||||
const container = document.getElementById('cc-devtools-root');
|
||||
if (container) {
|
||||
render(<App />, container);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mount);
|
||||
} else {
|
||||
mount();
|
||||
}
|
||||
119
packages/cccdev-template-3x/src/models/ComponentModels.ts
Normal file
119
packages/cccdev-template-3x/src/models/ComponentModels.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { PropDef } from './NodeModel';
|
||||
|
||||
export interface ComponentViewModel {
|
||||
props: PropDef[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function getComponentViewModel(
|
||||
name: string,
|
||||
componentGetter: () => any,
|
||||
): ComponentViewModel | null {
|
||||
switch (name) {
|
||||
case 'cc.UITransform':
|
||||
return new CCUITransformModel(componentGetter);
|
||||
case 'cc.Label':
|
||||
return new CCLabelModel(componentGetter);
|
||||
case 'cc.Sprite':
|
||||
return new CCSpriteModel(componentGetter);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class CCUITransformModel implements ComponentViewModel {
|
||||
props: PropDef[] = [
|
||||
{ name: 'Width', key: 'width' },
|
||||
{ name: 'Height', key: 'height' },
|
||||
{ name: 'Anchor X', key: 'anchorX' },
|
||||
{ name: 'Anchor Y', key: 'anchorY' },
|
||||
];
|
||||
|
||||
constructor(private getter: () => any) {}
|
||||
|
||||
get width(): number {
|
||||
return this.getter()?.contentSize.width ?? 0;
|
||||
}
|
||||
set width(v: number) {
|
||||
const c = this.getter();
|
||||
if (!c) return;
|
||||
c.setContentSize(v, c.contentSize.height);
|
||||
}
|
||||
|
||||
get height(): number {
|
||||
return this.getter()?.contentSize.height ?? 0;
|
||||
}
|
||||
set height(v: number) {
|
||||
const c = this.getter();
|
||||
if (!c) return;
|
||||
c.setContentSize(c.contentSize.width, v);
|
||||
}
|
||||
|
||||
get anchorX(): number {
|
||||
return this.getter()?.anchorPoint.x ?? 0;
|
||||
}
|
||||
set anchorX(v: number) {
|
||||
const c = this.getter();
|
||||
if (!c) return;
|
||||
c.setAnchorPoint(v, c.anchorPoint.y);
|
||||
}
|
||||
|
||||
get anchorY(): number {
|
||||
return this.getter()?.anchorPoint.y ?? 0;
|
||||
}
|
||||
set anchorY(v: number) {
|
||||
const c = this.getter();
|
||||
if (!c) return;
|
||||
c.setAnchorPoint(c.anchorPoint.x, v);
|
||||
}
|
||||
}
|
||||
|
||||
class CCLabelModel implements ComponentViewModel {
|
||||
props: PropDef[] = [
|
||||
{ name: 'String', key: 'string' },
|
||||
{ name: 'Color', key: 'color' },
|
||||
{ name: 'Font Size', key: 'fontSize' },
|
||||
{ name: 'Line Height', key: 'lineHeight' },
|
||||
];
|
||||
constructor(private getter: () => any) {}
|
||||
get string(): string {
|
||||
return this.getter()?.string ?? '';
|
||||
}
|
||||
set string(v: string) {
|
||||
const c = this.getter();
|
||||
if (c) c.string = v;
|
||||
}
|
||||
get color(): any {
|
||||
return this.getter()?.color;
|
||||
}
|
||||
set color(v: any) {
|
||||
const c = this.getter();
|
||||
if (c) c.color = v;
|
||||
}
|
||||
get fontSize(): number {
|
||||
return this.getter()?.fontSize ?? 0;
|
||||
}
|
||||
set fontSize(v: number) {
|
||||
const c = this.getter();
|
||||
if (c) c.fontSize = v;
|
||||
}
|
||||
get lineHeight(): number {
|
||||
return this.getter()?.lineHeight ?? 0;
|
||||
}
|
||||
set lineHeight(v: number) {
|
||||
const c = this.getter();
|
||||
if (c) c.lineHeight = v;
|
||||
}
|
||||
}
|
||||
|
||||
class CCSpriteModel implements ComponentViewModel {
|
||||
props: PropDef[] = [{ name: 'Color', key: 'color' }];
|
||||
constructor(private getter: () => any) {}
|
||||
get color(): any {
|
||||
return this.getter()?.color;
|
||||
}
|
||||
set color(v: any) {
|
||||
const c = this.getter();
|
||||
if (c) c.color = v;
|
||||
}
|
||||
}
|
||||
83
packages/cccdev-template-3x/src/models/NodeModel.ts
Normal file
83
packages/cccdev-template-3x/src/models/NodeModel.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { selectedNode } from '../store';
|
||||
|
||||
export interface PropDef {
|
||||
name: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class NodeModel {
|
||||
static readonly props: PropDef[] = [
|
||||
{ name: 'Name', key: 'nodeName' },
|
||||
{ name: 'X', key: 'x' },
|
||||
{ name: 'Y', key: 'y' },
|
||||
{ name: 'Z', key: 'z' },
|
||||
{ name: 'Scale X', key: 'scaleX' },
|
||||
{ name: 'Scale Y', key: 'scaleY' },
|
||||
{ name: 'Scale Z', key: 'scaleZ' },
|
||||
];
|
||||
|
||||
private static get node(): any {
|
||||
return selectedNode.value;
|
||||
}
|
||||
|
||||
static get nodeName(): string {
|
||||
return this.node?.name ?? '';
|
||||
}
|
||||
static set nodeName(v: string) {
|
||||
if (this.node) this.node.name = v;
|
||||
}
|
||||
|
||||
static get x(): number {
|
||||
return this.node?.getPosition().x ?? 0;
|
||||
}
|
||||
static set x(v: number) {
|
||||
if (!this.node) return;
|
||||
const p = this.node.getPosition();
|
||||
this.node.setPosition(v, p.y, p.z);
|
||||
}
|
||||
|
||||
static get y(): number {
|
||||
return this.node?.getPosition().y ?? 0;
|
||||
}
|
||||
static set y(v: number) {
|
||||
if (!this.node) return;
|
||||
const p = this.node.getPosition();
|
||||
this.node.setPosition(p.x, v, p.z);
|
||||
}
|
||||
|
||||
static get z(): number {
|
||||
return this.node?.getPosition().z ?? 0;
|
||||
}
|
||||
static set z(v: number) {
|
||||
if (!this.node) return;
|
||||
const p = this.node.getPosition();
|
||||
this.node.setPosition(p.x, p.y, v);
|
||||
}
|
||||
|
||||
static get scaleX(): number {
|
||||
return this.node?.getScale().x ?? 1;
|
||||
}
|
||||
static set scaleX(v: number) {
|
||||
if (!this.node) return;
|
||||
const s = this.node.getScale();
|
||||
this.node.setScale(v, s.y, s.z);
|
||||
}
|
||||
|
||||
static get scaleY(): number {
|
||||
return this.node?.getScale().y ?? 1;
|
||||
}
|
||||
static set scaleY(v: number) {
|
||||
if (!this.node) return;
|
||||
const s = this.node.getScale();
|
||||
this.node.setScale(s.x, v, s.z);
|
||||
}
|
||||
|
||||
static get scaleZ(): number {
|
||||
return this.node?.getScale().z ?? 1;
|
||||
}
|
||||
static set scaleZ(v: number) {
|
||||
if (!this.node) return;
|
||||
const s = this.node.getScale();
|
||||
this.node.setScale(s.x, s.y, v);
|
||||
}
|
||||
}
|
||||
40
packages/cccdev-template-3x/src/store.ts
Normal file
40
packages/cccdev-template-3x/src/store.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { signal, computed } from '@preact/signals';
|
||||
|
||||
/** 当前选中的 cc.Node */
|
||||
export const selectedNode = signal<any>(null);
|
||||
|
||||
/** 每帧 toggle(1 / -1),驱动属性面板重渲 */
|
||||
export const updateTick = signal<number>(1);
|
||||
|
||||
/** DevTools 面板是否展开 */
|
||||
export const devtoolsOpen = signal<boolean>(!!localStorage.getItem('cc_devtools_show'));
|
||||
|
||||
/** Profiler 浮窗是否展开 */
|
||||
export const profilerOpen = signal<boolean>(false);
|
||||
|
||||
/** 节点树数据(每帧重建) */
|
||||
export interface TreeNode {
|
||||
uuid: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
children: TreeNode[];
|
||||
path: string[];
|
||||
}
|
||||
export const treeData = signal<TreeNode[]>([]);
|
||||
|
||||
/** 已展开节点的 uuid 集合 */
|
||||
export const expandedUuids = signal<Set<string>>(new Set());
|
||||
|
||||
/** 是否有节点被选中且有效 */
|
||||
export const hasSelection = computed(() => selectedNode.value !== null);
|
||||
|
||||
/** 节点搜索关键词 */
|
||||
export const searchQuery = signal<string>('');
|
||||
|
||||
devtoolsOpen.subscribe((val) => {
|
||||
if (val) {
|
||||
localStorage.setItem('cc_devtools_show', '1');
|
||||
} else {
|
||||
localStorage.removeItem('cc_devtools_show');
|
||||
}
|
||||
});
|
||||
519
packages/cccdev-template-3x/src/style.css
Normal file
519
packages/cccdev-template-3x/src/style.css
Normal file
@@ -0,0 +1,519 @@
|
||||
/* ── CSS 变量(与 preview-template 主题保持一致) ── */
|
||||
:root {
|
||||
--bg-base: #16161e;
|
||||
--bg-panel: #1c1c28;
|
||||
--bg-control: #252535;
|
||||
--bg-hover: #2e2e48;
|
||||
--bg-selected: rgba(108, 99, 255, 0.22);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-focus: rgba(108, 99, 255, 0.6);
|
||||
--accent: #6c63ff;
|
||||
--accent-light: #a89dff;
|
||||
--text-primary: #d0d0f0;
|
||||
--text-muted: #7878a0;
|
||||
--text-dim: rgba(208, 208, 240, 0.35);
|
||||
--radius: 5px;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
/* ── Toolbar 开关按钮 ── */
|
||||
.ccdev-toggle {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
z-index: 9999;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-control);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.ccdev-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-focus);
|
||||
color: var(--accent-light);
|
||||
}
|
||||
.ccdev-toggle.active {
|
||||
background: rgba(108, 99, 255, 0.18);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
/* ── 浮层主容器 ── */
|
||||
#cc-devtools {
|
||||
position: fixed;
|
||||
/* top / height 由 App.tsx 内联 style 动态注入,跟随 toolbar 实际高度 */
|
||||
top: 42px;
|
||||
right: 8px;
|
||||
width: var(--devtools-width, 320px);
|
||||
height: calc(100vh - 42px);
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 8888;
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
transition: width 0s;
|
||||
}
|
||||
|
||||
/* ── GitHub 链接 ── */
|
||||
.ccdev-github {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 2;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
color 0.15s,
|
||||
background 0.15s;
|
||||
}
|
||||
.ccdev-github:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* ── 拖拽调整宽度手柄 ── */
|
||||
#cc-devtools-resize {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 5px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
#cc-devtools-resize:hover,
|
||||
#cc-devtools-resize.dragging {
|
||||
background: rgba(108, 99, 255, 0.4);
|
||||
}
|
||||
|
||||
#cc-devtools * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Panel 标题 ── */
|
||||
.panel-header {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
padding: 0 10px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── 节点树面板 ── */
|
||||
.tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── 搜索栏 ── */
|
||||
.tree-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
gap: 6px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-search-icon {
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.tree-search-input {
|
||||
flex: 1;
|
||||
height: 22px;
|
||||
background: var(--bg-control);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
min-width: 0;
|
||||
}
|
||||
.tree-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tree-search-input:focus {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.tree-search-clear {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
.tree-search-clear:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tree-search-count {
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.tree-highlight {
|
||||
background: rgba(108, 99, 255, 0.45);
|
||||
color: #fff;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.tree-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.tree-scroll::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.tree-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.tree-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(108, 99, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tree-empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
margin: 1px 4px;
|
||||
transition: background 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tree-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.tree-row.selected {
|
||||
background: var(--bg-selected);
|
||||
}
|
||||
.tree-row.inactive .tree-label {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.tree-arrow {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s;
|
||||
display: inline-block;
|
||||
}
|
||||
.tree-arrow.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.tree-arrow.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── 属性面板 ── */
|
||||
.prop-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.prop-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.prop-scroll::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.prop-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.prop-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(108, 99, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.prop-empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
/* ── 组件折叠块 ── */
|
||||
.comp-panel {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.comp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-panel);
|
||||
gap: 6px;
|
||||
user-select: none;
|
||||
}
|
||||
.comp-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.comp-name {
|
||||
flex: 1;
|
||||
color: var(--accent-light);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.comp-arrow {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comp-props {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.comp-empty {
|
||||
padding: 6px 12px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.comp-enabled {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 属性行 ── */
|
||||
.prop-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
.prop-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.prop-name {
|
||||
width: 72px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prop-value {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.prop-input {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
background: var(--bg-control);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.prop-input:focus {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.prop-checkbox {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prop-color {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
padding: 1px 3px;
|
||||
background: var(--bg-control);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prop-unknown {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── 通用图标按钮 ── */
|
||||
.icon-btn {
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
padding: 0 5px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-focus);
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
/* ── Profiler 浮层 ── */
|
||||
.profiler-float {
|
||||
position: fixed;
|
||||
width: 220px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid rgba(108, 99, 255, 0.4);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profiler-drag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
background: var(--bg-hover);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.profiler-body {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.profiler-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.profiler-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.profiler-desc {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.profiler-val {
|
||||
color: var(--accent-light);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 数字输入框去掉 spin ── */
|
||||
input[type='number']::-webkit-outer-spin-button,
|
||||
input[type='number']::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
Reference in New Issue
Block a user