fix(rgov): mobile-friendly canvas with collapsible sidebars and touch support
- Collapsible palette sidebar (hamburger toggle, hidden by default on mobile) - Pinch-to-zoom and two-finger pan for touch/pen - Larger touch targets for ports and zoom controls - Responsive text sizing and compact toolbar on small screens - Detail panel goes full-width on very small screens - touch-action: none on SVG to prevent browser gesture conflicts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cc12f7a936
commit
3a3b83c807
|
|
@ -675,6 +675,136 @@ const STYLES = `
|
|||
.gc-detail__delete:hover {
|
||||
background: #7f1d1d;
|
||||
}
|
||||
|
||||
/* ── Collapsible sidebar toggle buttons ── */
|
||||
|
||||
.gc-sidebar-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gc-sidebar-toggle:hover {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.gc-sidebar-toggle.active {
|
||||
background: #334155;
|
||||
color: #38bdf8;
|
||||
border-color: #38bdf8;
|
||||
}
|
||||
|
||||
/* ── Palette collapsed state ── */
|
||||
|
||||
.gc-palette {
|
||||
transition: width 0.2s, padding 0.2s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.gc-palette.collapsed {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-right: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Touch & pen support ── */
|
||||
|
||||
.gc-canvas svg {
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Mobile responsive ── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.gc-toolbar {
|
||||
padding: 6px 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
.gc-toolbar__title {
|
||||
font-size: 13px;
|
||||
gap: 6px;
|
||||
}
|
||||
.gc-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.gc-palette {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-right: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gc-palette.mobile-open {
|
||||
width: 160px;
|
||||
padding: 10px 6px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #334155;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
background: #1e293b;
|
||||
}
|
||||
.gc-detail.open {
|
||||
width: 240px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
background: #1e293b;
|
||||
}
|
||||
.gc-zoom-controls {
|
||||
bottom: 10px;
|
||||
}
|
||||
.gc-zoom-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.gc-zoom-level {
|
||||
font-size: 12px;
|
||||
min-width: 44px;
|
||||
}
|
||||
/* Larger port hit targets for touch */
|
||||
.gc-port-hit {
|
||||
r: 18;
|
||||
}
|
||||
.gc-palette__card {
|
||||
padding: 8px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.gc-toolbar__title span:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
.gc-detail.open {
|
||||
width: 100%;
|
||||
}
|
||||
.gc-palette.mobile-open {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Component ──
|
||||
|
|
@ -692,6 +822,10 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
private canvasPanY = 0;
|
||||
private showGrid = true;
|
||||
|
||||
// Sidebar state
|
||||
private paletteOpen = false;
|
||||
private detailOpen = false;
|
||||
|
||||
// Interaction
|
||||
private isPanning = false;
|
||||
private panStartX = 0;
|
||||
|
|
@ -704,9 +838,15 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
private dragNodeStartX = 0;
|
||||
private dragNodeStartY = 0;
|
||||
|
||||
// Touch — pinch-to-zoom
|
||||
private activeTouches: Map<number, { x: number; y: number }> = new Map();
|
||||
private pinchStartDist = 0;
|
||||
private pinchStartZoom = 1;
|
||||
private pinchMidX = 0;
|
||||
private pinchMidY = 0;
|
||||
|
||||
// Selection & detail panel
|
||||
private selectedNodeId: string | null = null;
|
||||
private detailOpen = false;
|
||||
|
||||
// Wiring
|
||||
private wiringActive = false;
|
||||
|
|
@ -719,6 +859,9 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
|
||||
private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
|
||||
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
|
||||
private _boundTouchStart: ((e: TouchEvent) => void) | null = null;
|
||||
private _boundTouchMove: ((e: TouchEvent) => void) | null = null;
|
||||
private _boundTouchEnd: ((e: TouchEvent) => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -735,6 +878,9 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove);
|
||||
if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp);
|
||||
if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown);
|
||||
if (this._boundTouchStart) document.removeEventListener('touchstart', this._boundTouchStart);
|
||||
if (this._boundTouchMove) document.removeEventListener('touchmove', this._boundTouchMove);
|
||||
if (this._boundTouchEnd) document.removeEventListener('touchend', this._boundTouchEnd);
|
||||
}
|
||||
|
||||
// ── Data init ──
|
||||
|
|
@ -811,6 +957,7 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
<div class="gc-root">
|
||||
<div class="gc-toolbar">
|
||||
<div class="gc-toolbar__title">
|
||||
<button class="gc-sidebar-toggle ${this.paletteOpen ? 'active' : ''}" id="btn-palette-toggle" title="Toggle palette">☰</button>
|
||||
<span>\u25A3 Governance Circuits</span>
|
||||
</div>
|
||||
<div class="gc-toolbar__actions">
|
||||
|
|
@ -822,7 +969,7 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
</div>
|
||||
|
||||
<div class="gc-canvas-area">
|
||||
<div class="gc-palette" id="palette">
|
||||
<div class="gc-palette ${this.paletteOpen ? 'mobile-open' : 'collapsed'}" id="palette">
|
||||
<div class="gc-palette__title">Node Types</div>
|
||||
${GOV_NODE_CATALOG.map(n => `
|
||||
<div class="gc-palette__card" data-node-type="${n.type}" draggable="true">
|
||||
|
|
@ -1354,6 +1501,18 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2);
|
||||
});
|
||||
|
||||
// Palette toggle
|
||||
this.shadow.getElementById('btn-palette-toggle')?.addEventListener('click', () => {
|
||||
this.paletteOpen = !this.paletteOpen;
|
||||
const pal = this.shadow.getElementById('palette');
|
||||
const btn = this.shadow.getElementById('btn-palette-toggle');
|
||||
if (pal) {
|
||||
pal.classList.toggle('collapsed', !this.paletteOpen);
|
||||
pal.classList.toggle('mobile-open', this.paletteOpen);
|
||||
}
|
||||
if (btn) btn.classList.toggle('active', this.paletteOpen);
|
||||
});
|
||||
|
||||
// SVG pointer events
|
||||
svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e));
|
||||
|
||||
|
|
@ -1365,6 +1524,14 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
document.addEventListener('pointerup', this._boundPointerUp);
|
||||
document.addEventListener('keydown', this._boundKeyDown);
|
||||
|
||||
// Touch: pinch-to-zoom + two-finger pan
|
||||
this._boundTouchStart = (e: TouchEvent) => this.handleTouchStart(e);
|
||||
this._boundTouchMove = (e: TouchEvent) => this.handleTouchMove(e);
|
||||
this._boundTouchEnd = (e: TouchEvent) => this.handleTouchEnd(e);
|
||||
svg.addEventListener('touchstart', this._boundTouchStart, { passive: false });
|
||||
svg.addEventListener('touchmove', this._boundTouchMove, { passive: false });
|
||||
svg.addEventListener('touchend', this._boundTouchEnd, { passive: false });
|
||||
|
||||
// Detail panel
|
||||
this.attachDetailListeners();
|
||||
}
|
||||
|
|
@ -1526,6 +1693,69 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Touch handlers (pinch-to-zoom, two-finger pan) ──
|
||||
|
||||
private handleTouchStart(e: TouchEvent) {
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
const t = e.changedTouches[i];
|
||||
this.activeTouches.set(t.identifier, { x: t.clientX, y: t.clientY });
|
||||
}
|
||||
if (this.activeTouches.size === 2) {
|
||||
e.preventDefault();
|
||||
const pts = [...this.activeTouches.values()];
|
||||
this.pinchStartDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
|
||||
this.pinchStartZoom = this.canvasZoom;
|
||||
this.pinchMidX = (pts[0].x + pts[1].x) / 2;
|
||||
this.pinchMidY = (pts[0].y + pts[1].y) / 2;
|
||||
this.panStartPanX = this.canvasPanX;
|
||||
this.panStartPanY = this.canvasPanY;
|
||||
}
|
||||
}
|
||||
|
||||
private handleTouchMove(e: TouchEvent) {
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
const t = e.changedTouches[i];
|
||||
this.activeTouches.set(t.identifier, { x: t.clientX, y: t.clientY });
|
||||
}
|
||||
if (this.activeTouches.size === 2) {
|
||||
e.preventDefault();
|
||||
const pts = [...this.activeTouches.values()];
|
||||
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
|
||||
const midX = (pts[0].x + pts[1].x) / 2;
|
||||
const midY = (pts[0].y + pts[1].y) / 2;
|
||||
|
||||
// Zoom
|
||||
const svg = this.shadow.getElementById('gc-svg') as SVGSVGElement | null;
|
||||
if (svg) {
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const scale = dist / this.pinchStartDist;
|
||||
const newZoom = Math.max(0.1, Math.min(4, this.pinchStartZoom * scale));
|
||||
const cx = this.pinchMidX - rect.left;
|
||||
const cy = this.pinchMidY - rect.top;
|
||||
this.canvasPanX = cx - (cx - this.panStartPanX) * (newZoom / this.pinchStartZoom);
|
||||
this.canvasPanY = cy - (cy - this.panStartPanY) * (newZoom / this.pinchStartZoom);
|
||||
this.canvasZoom = newZoom;
|
||||
|
||||
// Pan offset from midpoint movement
|
||||
this.canvasPanX += (midX - this.pinchMidX);
|
||||
this.canvasPanY += (midY - this.pinchMidY);
|
||||
this.pinchMidX = midX;
|
||||
this.pinchMidY = midY;
|
||||
|
||||
this.updateCanvasTransform();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleTouchEnd(e: TouchEvent) {
|
||||
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||
this.activeTouches.delete(e.changedTouches[i].identifier);
|
||||
}
|
||||
if (this.activeTouches.size < 2) {
|
||||
this.pinchStartDist = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyDown(e: KeyboardEvent) {
|
||||
const tag = (e.target as Element)?.tagName;
|
||||
const isEditing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||
|
|
|
|||
Loading…
Reference in New Issue