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
2e2fbae8bf
commit
ba7a5733b8
|
|
@ -675,6 +675,136 @@ const STYLES = `
|
||||||
.gc-detail__delete:hover {
|
.gc-detail__delete:hover {
|
||||||
background: #7f1d1d;
|
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 ──
|
// ── Component ──
|
||||||
|
|
@ -692,6 +822,10 @@ export class FolkGovCircuit extends HTMLElement {
|
||||||
private canvasPanY = 0;
|
private canvasPanY = 0;
|
||||||
private showGrid = true;
|
private showGrid = true;
|
||||||
|
|
||||||
|
// Sidebar state
|
||||||
|
private paletteOpen = false;
|
||||||
|
private detailOpen = false;
|
||||||
|
|
||||||
// Interaction
|
// Interaction
|
||||||
private isPanning = false;
|
private isPanning = false;
|
||||||
private panStartX = 0;
|
private panStartX = 0;
|
||||||
|
|
@ -704,9 +838,15 @@ export class FolkGovCircuit extends HTMLElement {
|
||||||
private dragNodeStartX = 0;
|
private dragNodeStartX = 0;
|
||||||
private dragNodeStartY = 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
|
// Selection & detail panel
|
||||||
private selectedNodeId: string | null = null;
|
private selectedNodeId: string | null = null;
|
||||||
private detailOpen = false;
|
|
||||||
|
|
||||||
// Wiring
|
// Wiring
|
||||||
private wiringActive = false;
|
private wiringActive = false;
|
||||||
|
|
@ -719,6 +859,9 @@ export class FolkGovCircuit extends HTMLElement {
|
||||||
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
|
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
|
||||||
private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
|
private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
|
||||||
private _boundKeyDown: ((e: KeyboardEvent) => 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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -735,6 +878,9 @@ export class FolkGovCircuit extends HTMLElement {
|
||||||
if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove);
|
if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove);
|
||||||
if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp);
|
if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp);
|
||||||
if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown);
|
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 ──
|
// ── Data init ──
|
||||||
|
|
@ -811,6 +957,7 @@ export class FolkGovCircuit extends HTMLElement {
|
||||||
<div class="gc-root">
|
<div class="gc-root">
|
||||||
<div class="gc-toolbar">
|
<div class="gc-toolbar">
|
||||||
<div class="gc-toolbar__title">
|
<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>
|
<span>\u25A3 Governance Circuits</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gc-toolbar__actions">
|
<div class="gc-toolbar__actions">
|
||||||
|
|
@ -822,7 +969,7 @@ export class FolkGovCircuit extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gc-canvas-area">
|
<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>
|
<div class="gc-palette__title">Node Types</div>
|
||||||
${GOV_NODE_CATALOG.map(n => `
|
${GOV_NODE_CATALOG.map(n => `
|
||||||
<div class="gc-palette__card" data-node-type="${n.type}" draggable="true">
|
<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);
|
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 pointer events
|
||||||
svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e));
|
svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e));
|
||||||
|
|
||||||
|
|
@ -1365,6 +1524,14 @@ export class FolkGovCircuit extends HTMLElement {
|
||||||
document.addEventListener('pointerup', this._boundPointerUp);
|
document.addEventListener('pointerup', this._boundPointerUp);
|
||||||
document.addEventListener('keydown', this._boundKeyDown);
|
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
|
// Detail panel
|
||||||
this.attachDetailListeners();
|
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) {
|
private handleKeyDown(e: KeyboardEvent) {
|
||||||
const tag = (e.target as Element)?.tagName;
|
const tag = (e.target as Element)?.tagName;
|
||||||
const isEditing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
const isEditing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue