feat: Entity 선택기능, Property Inspector 패널, Layer 관리 패널, 드래그/접기/크기조절 UI 구현

This commit is contained in:
minsung
2026-07-31 13:38:14 +09:00
parent f7564d4abf
commit 199317d575
7 changed files with 846 additions and 27 deletions
+176
View File
@@ -0,0 +1,176 @@
/**
* Layer Panel manager for dwg-dxf-viewer-sample.
* Renders layer items, color swatches, entity counts, search filters, and visibility toggles.
*/
import type { Viewer2D } from './viewer2d/Viewer2D';
export interface LayerItem {
name: string;
colorHex: string;
count: number;
visible: boolean;
}
export class LayerPanelManager {
private viewer: Viewer2D;
private listEl: HTMLElement;
private countEl: HTMLElement | null;
private searchInputEl: HTMLInputElement | null;
private hiddenLayers: Set<string> = new Set();
private searchQuery: string = '';
constructor(
viewer: Viewer2D,
listEl: HTMLElement,
options?: {
countEl?: HTMLElement | null;
searchInputEl?: HTMLInputElement | null;
showAllBtnEl?: HTMLElement | null;
hideAllBtnEl?: HTMLElement | null;
}
) {
this.viewer = viewer;
this.listEl = listEl;
this.countEl = options?.countEl || null;
this.searchInputEl = options?.searchInputEl || null;
if (this.searchInputEl) {
this.searchInputEl.addEventListener('input', (e) => {
this.searchQuery = (e.target as HTMLInputElement).value.trim().toLowerCase();
this.render();
});
}
if (options?.showAllBtnEl) {
options.showAllBtnEl.addEventListener('click', () => this.showAllLayers());
}
if (options?.hideAllBtnEl) {
options.hideAllBtnEl.addEventListener('click', () => this.hideAllLayers());
}
}
/** Refresh and re-render layer list from Viewer2D state */
public update(): void {
this.hiddenLayers.clear();
const layers = this.viewer.getLayerInfo() || [];
for (const l of layers) {
if (!l.visible) {
this.hiddenLayers.add(l.name);
}
}
this.render();
}
public showAllLayers(): void {
this.hiddenLayers.clear();
this.viewer.setHiddenLayers(this.hiddenLayers);
this.render();
}
public hideAllLayers(): void {
const layers = this.viewer.getLayerInfo() || [];
for (const l of layers) {
this.hiddenLayers.add(l.name);
}
this.viewer.setHiddenLayers(this.hiddenLayers);
this.render();
}
public toggleLayer(name: string): void {
if (this.hiddenLayers.has(name)) {
this.hiddenLayers.delete(name);
} else {
this.hiddenLayers.add(name);
}
this.viewer.setHiddenLayers(this.hiddenLayers);
this.render();
}
public render(): void {
if (!this.listEl) return;
const layers: LayerItem[] = this.viewer.getLayerInfo() || [];
if (this.countEl) {
this.countEl.textContent = `${layers.length}`;
}
if (!layers.length) {
this.listEl.innerHTML = `
<div style="padding:30px 16px;text-align:center;color:var(--muted);font-size:12px">
도면에 레이어 정보가 없습니다
</div>`;
return;
}
const filtered = layers.filter((l) =>
l.name.toLowerCase().includes(this.searchQuery)
);
if (!filtered.length) {
this.listEl.innerHTML = `
<div style="padding:24px 16px;text-align:center;color:var(--muted);font-size:12px">
검색 결과가 없습니다 ("${escapeHtml(this.searchQuery)}")
</div>`;
return;
}
this.listEl.innerHTML = filtered
.map((l) => {
const isVisible = !this.hiddenLayers.has(l.name);
const opacity = isVisible ? '1' : '0.4';
const eyeIcon = isVisible ? '👁️' : '🕶️';
const eyeTitle = isVisible ? '숨기기' : '표시하기';
return `
<div class="layer-row" data-name="${escapeHtml(l.name)}"
style="display:flex;align-items:center;gap:10px;padding:7px 10px;border-bottom:1px solid var(--line);opacity:${opacity};transition:opacity .15s;user-select:none">
<span style="width:10px;height:10px;border-radius:3px;background:${l.colorHex || '#888'};flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.15)"></span>
<span class="mono" style="flex:1;font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(l.name)}">
${escapeHtml(l.name)}
</span>
<span class="mono" style="font-size:11px;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 6px;border-radius:4px;flex-shrink:0">
${l.count}
</span>
<button type="button" class="eye-btn" data-toggle="${escapeHtml(l.name)}" title="${eyeTitle}"
style="background:none;border:none;cursor:pointer;font-size:13px;padding:2px 4px;border-radius:4px;color:var(--ink);transition:transform .1s">
${eyeIcon}
</button>
</div>`;
})
.join('');
// Bind eye button click events
this.listEl.querySelectorAll('.eye-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const layerName = (btn as HTMLElement).dataset.toggle;
if (layerName) {
this.toggleLayer(layerName);
}
});
});
// Also toggle when clicking row
this.listEl.querySelectorAll('.layer-row').forEach((row) => {
row.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.closest('.eye-btn')) return;
const layerName = (row as HTMLElement).dataset.name;
if (layerName) {
this.toggleLayer(layerName);
}
});
});
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}