rspace-online/modules/rsocials/components/folk-campaign-planner.ts

1915 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-campaign-planner> — n8n-style campaign flow canvas.
*
* Renders campaign posts, threads, platforms, audiences as draggable nodes
* on an SVG canvas with ports, wiring, edges, inline config, and local-first
* persistence via Automerge.
*
* Attributes:
* space — space slug (default "demo")
*/
import type {
CampaignNodeType,
CampaignPlannerNode,
CampaignEdge,
CampaignEdgeType,
CampaignFlow,
PostNodeData,
ThreadNodeData,
PlatformNodeData,
AudienceNodeData,
PhaseNodeData,
} from '../schemas';
import { SocialsLocalFirstClient } from '../local-first-client';
import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
// ── Port definitions ──
interface PortDef {
kind: string;
dir: 'in' | 'out';
xFrac: number;
yFrac: number;
color: string;
connectsTo?: string[];
}
const CAMPAIGN_PORT_DEFS: Record<CampaignNodeType, PortDef[]> = {
post: [
{ kind: 'publish', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#10b981', connectsTo: ['content-in', 'target-in'] },
{ kind: 'sequence-out', dir: 'out', xFrac: 0.5, yFrac: 1.0, color: '#8b5cf6', connectsTo: ['sequence-in'] },
{ kind: 'sequence-in', dir: 'in', xFrac: 0.5, yFrac: 0.0, color: '#8b5cf6' },
],
thread: [
{ kind: 'publish', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#10b981', connectsTo: ['content-in', 'target-in'] },
{ kind: 'sequence-out', dir: 'out', xFrac: 0.5, yFrac: 1.0, color: '#8b5cf6', connectsTo: ['sequence-in'] },
{ kind: 'sequence-in', dir: 'in', xFrac: 0.5, yFrac: 0.0, color: '#8b5cf6' },
],
platform: [
{ kind: 'content-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#10b981' },
],
audience: [
{ kind: 'target-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#f59e0b' },
],
phase: [],
};
// ── Edge type → visual config ──
const EDGE_STYLES: Record<CampaignEdgeType, { width: number; dash: string; animated: boolean }> = {
publish: { width: 3, dash: '8 4', animated: true },
sequence: { width: 2, dash: '', animated: false },
target: { width: 2, dash: '4 6', animated: false },
};
const EDGE_COLORS: Record<CampaignEdgeType, string> = {
publish: '#10b981',
sequence: '#8b5cf6',
target: '#f59e0b',
};
// ── Node sizes ──
function getNodeSize(node: CampaignPlannerNode): { w: number; h: number } {
switch (node.type) {
case 'post': return { w: 240, h: 120 };
case 'thread': return { w: 240, h: 100 };
case 'platform': return { w: 180, h: 80 };
case 'audience': return { w: 180, h: 80 };
case 'phase': {
const d = node.data as PhaseNodeData;
return { w: d.size.w, h: d.size.h };
}
default: return { w: 200, h: 100 };
}
}
function getPortDefs(type: CampaignNodeType): PortDef[] {
return CAMPAIGN_PORT_DEFS[type] || [];
}
function getPortPosition(node: CampaignPlannerNode, portKind: string): { x: number; y: number } | null {
const s = getNodeSize(node);
const def = getPortDefs(node.type).find(p => p.kind === portKind);
if (!def) return null;
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac };
}
// ── Helpers ──
function esc(s: string): string {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function getUsername(): string | null {
try {
const raw = localStorage.getItem('encryptid:session');
if (raw) {
const s = JSON.parse(raw);
return s.username || null;
}
} catch { /* ignore */ }
return null;
}
// ── Component ──
class FolkCampaignPlanner extends HTMLElement {
private shadow: ShadowRoot;
private space = '';
private currentView: 'canvas' | 'timeline' | 'platform' | 'table' = 'canvas';
private get basePath() {
const host = window.location.hostname;
if (host.endsWith('.rspace.online') || host.endsWith('.rsocials.online')) {
return '/rsocials/';
}
return `/${this.space}/rsocials/`;
}
// Data
private nodes: CampaignPlannerNode[] = [];
private edges: CampaignEdge[] = [];
private currentFlowId = '';
private flowName = '';
// Canvas state
private canvasZoom = 1;
private canvasPanX = 0;
private canvasPanY = 0;
// Interaction state
private isPanning = false;
private panStartX = 0;
private panStartY = 0;
private panStartPanX = 0;
private panStartPanY = 0;
private draggingNodeId: string | null = null;
private dragStartX = 0;
private dragStartY = 0;
private dragNodeStartX = 0;
private dragNodeStartY = 0;
private nodeDragStarted = false;
// Selection
private selectedNodeId: string | null = null;
private selectedEdgeKey: string | null = null;
private inlineEditNodeId: string | null = null;
// Wiring
private wiringActive = false;
private wiringSourceNodeId: string | null = null;
private wiringSourcePortKind: string | null = null;
private wiringDragging = false;
private wiringPointerX = 0;
private wiringPointerY = 0;
// Edge dragging
private draggingEdgeKey: string | null = null;
// Touch
private isTouchPanning = false;
private lastTouchCenter: { x: number; y: number } | null = null;
private lastTouchDist: number | null = null;
// Postiz
private postizOpen = false;
// Persistence
private localFirstClient: SocialsLocalFirstClient | null = null;
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private _lfcUnsub: (() => void) | null = null;
// Context menu
private contextMenuX = 0;
private contextMenuY = 0;
private contextMenuOpen = false;
private contextMenuCanvasX = 0;
private contextMenuCanvasY = 0;
// Bound listeners (for cleanup)
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
private _initialLoad = true;
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.space = this.getAttribute('space') || 'demo';
this.initData();
}
disconnectedCallback() {
if (this._lfcUnsub) this._lfcUnsub();
if (this.saveTimer) clearTimeout(this.saveTimer);
if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove);
if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp);
if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown);
this.localFirstClient?.disconnect();
}
// ── Data init ──
private async initData() {
try {
this.localFirstClient = new SocialsLocalFirstClient(this.space);
await this.localFirstClient.init();
await this.localFirstClient.subscribe();
this._lfcUnsub = this.localFirstClient.onChange((doc) => {
if (!this.currentFlowId || this.saveTimer) return;
const flow = doc.campaignFlows?.[this.currentFlowId];
if (flow) {
this.nodes = flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } }));
this.edges = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined }));
if (this.currentView === 'canvas') {
this.drawCanvasContent();
} else {
this.switchView(this.currentView);
}
}
});
const activeId = this.localFirstClient.getActiveFlowId();
const flows = this.localFirstClient.listCampaignFlows();
if (activeId && this.localFirstClient.getCampaignFlow(activeId)) {
this.loadFlow(activeId);
} else if (flows.length > 0) {
this.loadFlow(flows[0].id);
} else {
// No flows — create demo
const demo = buildDemoCampaignFlow();
const username = getUsername();
if (username) demo.createdBy = `did:encryptid:${username}`;
this.localFirstClient.saveCampaignFlow(demo);
this.localFirstClient.setActiveFlow(demo.id);
this.loadFlow(demo.id);
}
} catch {
console.warn('[CampaignPlanner] Local-first init failed, using demo data');
const demo = buildDemoCampaignFlow();
this.currentFlowId = demo.id;
this.flowName = demo.name;
this.nodes = demo.nodes;
this.edges = demo.edges;
}
this.render();
requestAnimationFrame(() => this.fitView());
}
private loadFlow(id: string) {
const flow = this.localFirstClient?.getCampaignFlow(id);
if (!flow) return;
this.currentFlowId = id;
this.flowName = flow.name;
this.nodes = flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } }));
this.edges = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined }));
this.localFirstClient?.setActiveFlow(id);
this.restoreViewport();
}
// ── Auto-save ──
private scheduleSave() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => { this.executeSave(); this.saveTimer = null; }, 1500);
}
private executeSave() {
if (this.localFirstClient && this.currentFlowId) {
this.localFirstClient.updateFlowNodesEdges(this.currentFlowId, this.nodes, this.edges);
} else if (this.currentFlowId) {
localStorage.setItem(`rsocials:flow:${this.currentFlowId}`, JSON.stringify({
id: this.currentFlowId, name: this.flowName,
nodes: this.nodes, edges: this.edges,
createdAt: Date.now(), updatedAt: Date.now(), createdBy: null,
}));
}
}
// ── Viewport persistence ──
private saveViewport() {
if (!this.currentFlowId) return;
try {
localStorage.setItem(`rsocials:vp:${this.currentFlowId}`, JSON.stringify({
x: this.canvasPanX, y: this.canvasPanY, z: this.canvasZoom,
}));
} catch { /* ignore */ }
}
private restoreViewport() {
if (!this.currentFlowId || this._initialLoad) return;
try {
const raw = localStorage.getItem(`rsocials:vp:${this.currentFlowId}`);
if (raw) {
const vp = JSON.parse(raw);
this.canvasPanX = vp.x;
this.canvasPanY = vp.y;
this.canvasZoom = vp.z;
}
} catch { /* ignore */ }
}
// ── Canvas transform ──
private updateCanvasTransform() {
const g = this.shadow.getElementById('canvas-transform');
if (g) g.setAttribute('transform', `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`);
this.updateZoomDisplay();
this.saveViewport();
}
private updateZoomDisplay() {
const el = this.shadow.getElementById('zoom-level');
if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`;
}
private fitView(retries = 3) {
const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
if (!svg || this.nodes.length === 0) return;
const rect = svg.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
if (retries > 0) requestAnimationFrame(() => this.fitView(retries - 1));
return;
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of this.nodes) {
const s = getNodeSize(n);
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + s.w);
maxY = Math.max(maxY, n.position.y + s.h);
}
const pad = 40;
const contentW = maxX - minX + pad * 2;
const contentH = maxY - minY + pad * 2;
const scaleX = rect.width / contentW;
const scaleY = rect.height / contentH;
this.canvasZoom = Math.max(0.5, Math.min(scaleX, scaleY, 1.5));
this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom;
this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom;
this.updateCanvasTransform();
this._initialLoad = false;
}
private zoomAt(screenX: number, screenY: number, factor: number) {
const oldZoom = this.canvasZoom;
const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor));
this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom);
this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom);
this.canvasZoom = newZoom;
this.updateCanvasTransform();
}
// ── Draw helpers (incremental updates) ──
private drawCanvasContent() {
const edgeLayer = this.shadow.getElementById('edge-layer');
const nodeLayer = this.shadow.getElementById('node-layer');
const wireLayer = this.shadow.getElementById('wire-layer');
if (!edgeLayer || !nodeLayer) return;
edgeLayer.innerHTML = this.renderAllEdges();
nodeLayer.innerHTML = this.renderAllNodes();
if (wireLayer) wireLayer.innerHTML = '';
if (this.selectedNodeId) this.updateSelectionHighlight();
}
private redrawEdges() {
const edgeLayer = this.shadow.getElementById('edge-layer');
if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
}
private updateNodePosition(node: CampaignPlannerNode) {
const nodeLayer = this.shadow.getElementById('node-layer');
if (!nodeLayer) return;
const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
if (!g) return;
const fo = g.querySelector('foreignObject');
if (fo) {
fo.setAttribute('x', String(node.position.x));
fo.setAttribute('y', String(node.position.y));
}
// Phase nodes use rect instead
const r = g.querySelector('rect.cp-phase-rect');
if (r) {
r.setAttribute('x', String(node.position.x));
r.setAttribute('y', String(node.position.y));
}
// Update text positions for phase
const texts = g.querySelectorAll('text');
if (node.type === 'phase' && texts.length >= 2) {
texts[0].setAttribute('x', String(node.position.x + 16));
texts[0].setAttribute('y', String(node.position.y + 24));
texts[1].setAttribute('x', String(node.position.x + 16));
texts[1].setAttribute('y', String(node.position.y + 40));
}
// Update port positions
const ports = g.querySelectorAll('.port-group');
const s = getNodeSize(node);
const defs = getPortDefs(node.type);
ports.forEach((pg, i) => {
if (i >= defs.length) return;
const cx = node.position.x + s.w * defs[i].xFrac;
const cy = node.position.y + s.h * defs[i].yFrac;
const hit = pg.querySelector('.port-hit');
const dot = pg.querySelector('.port-dot');
const arrow = pg.querySelector('.port-arrow');
if (hit) { hit.setAttribute('cx', String(cx)); hit.setAttribute('cy', String(cy)); }
if (dot) { dot.setAttribute('cx', String(cx)); dot.setAttribute('cy', String(cy)); }
if (arrow) {
// Recalculate arrow path
const def = defs[i];
arrow.setAttribute('d', this.portArrowPath(cx, cy, def));
}
});
}
private portArrowPath(cx: number, cy: number, def: PortDef): string {
if (def.xFrac === 0) {
// Left port: arrow pointing left
return `M ${cx + 2} ${cy - 3} l -5 3 l 5 3`;
} else if (def.xFrac === 1) {
// Right port: arrow pointing right
return `M ${cx - 2} ${cy - 3} l 5 3 l -5 3`;
} else if (def.dir === 'out') {
// Bottom port: arrow pointing down
return `M ${cx - 3} ${cy + 2} l 3 5 l 3 -5`;
} else {
// Top port: arrow pointing up
return `M ${cx - 3} ${cy - 2} l 3 -5 l 3 5`;
}
}
// ── Selection ──
private updateSelectionHighlight() {
const nodeLayer = this.shadow.getElementById('node-layer');
if (!nodeLayer) return;
nodeLayer.querySelectorAll('.cp-node').forEach(g => {
g.classList.toggle('selected', g.getAttribute('data-node-id') === this.selectedNodeId);
});
}
// ── Wiring ──
private enterWiring(nodeId: string, portKind: string) {
this.wiringActive = true;
this.wiringSourceNodeId = nodeId;
this.wiringSourcePortKind = portKind;
this.wiringDragging = false;
const canvas = this.shadow.getElementById('cp-canvas');
if (canvas) canvas.classList.add('wiring');
this.applyWiringClasses();
}
private cancelWiring() {
this.wiringActive = false;
this.wiringSourceNodeId = null;
this.wiringSourcePortKind = null;
this.wiringDragging = false;
const canvas = this.shadow.getElementById('cp-canvas');
if (canvas) canvas.classList.remove('wiring');
const wireLayer = this.shadow.getElementById('wire-layer');
if (wireLayer) wireLayer.innerHTML = '';
this.clearWiringClasses();
}
private applyWiringClasses() {
const nodeLayer = this.shadow.getElementById('node-layer');
if (!nodeLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return;
const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId);
if (!sourceNode) return;
const sourceDef = getPortDefs(sourceNode.type).find(p => p.kind === this.wiringSourcePortKind);
const connectsTo = sourceDef?.connectsTo || [];
nodeLayer.querySelectorAll('.port-group').forEach(pg => {
const nid = pg.getAttribute('data-node-id');
const kind = pg.getAttribute('data-port-kind');
const dir = pg.getAttribute('data-port-dir');
if (nid === this.wiringSourceNodeId && kind === this.wiringSourcePortKind) {
pg.classList.add('port-group--wiring-source');
} else if (dir === 'in' && connectsTo.includes(kind!) && nid !== this.wiringSourceNodeId) {
// Check no existing edge
const exists = this.edges.some(e => e.from === this.wiringSourceNodeId && e.to === nid);
if (!exists) {
pg.classList.add('port-group--wiring-target');
} else {
pg.classList.add('port-group--wiring-dimmed');
}
} else {
pg.classList.add('port-group--wiring-dimmed');
}
});
}
private clearWiringClasses() {
const nodeLayer = this.shadow.getElementById('node-layer');
if (!nodeLayer) return;
nodeLayer.querySelectorAll('.port-group').forEach(pg => {
pg.classList.remove('port-group--wiring-source', 'port-group--wiring-target', 'port-group--wiring-dimmed');
});
}
private completeWiring(targetNodeId: string) {
if (!this.wiringSourceNodeId || !this.wiringSourcePortKind) return;
const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId);
const targetNode = this.nodes.find(n => n.id === targetNodeId);
if (!sourceNode || !targetNode) { this.cancelWiring(); return; }
// Determine edge type from port kinds
let edgeType: CampaignEdgeType = 'publish';
if (this.wiringSourcePortKind === 'sequence-out') {
edgeType = 'sequence';
} else {
// Check what kind of input the target has
const targetPortKind = this.getWiringTargetPort(targetNode);
if (targetPortKind === 'target-in') edgeType = 'target';
}
const edgeId = `e-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
this.edges.push({
id: edgeId,
from: this.wiringSourceNodeId,
to: targetNodeId,
type: edgeType,
});
this.cancelWiring();
this.drawCanvasContent();
this.scheduleSave();
}
private getWiringTargetPort(targetNode: CampaignPlannerNode): string | null {
if (!this.wiringSourcePortKind) return null;
const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId);
if (!sourceNode) return null;
const sourceDef = getPortDefs(sourceNode.type).find(p => p.kind === this.wiringSourcePortKind);
if (!sourceDef) return null;
const targetDefs = getPortDefs(targetNode.type);
for (const td of targetDefs) {
if (td.dir === 'in' && sourceDef.connectsTo?.includes(td.kind)) return td.kind;
}
return null;
}
private updateWiringTempLine() {
const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
const wireLayer = this.shadow.getElementById('wire-layer');
if (!svg || !wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return;
const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId);
if (!sourceNode) return;
const portPos = getPortPosition(sourceNode, this.wiringSourcePortKind);
if (!portPos) return;
const rect = svg.getBoundingClientRect();
const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom;
const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom;
const x1 = portPos.x;
const y1 = portPos.y;
// Bezier temp path
const dx = x2 - x1;
const dy = y2 - y1;
const cx1 = x1 + dx * 0.4;
const cy1 = y1;
const cx2 = x2 - dx * 0.4;
const cy2 = y2;
const d = `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
wireLayer.innerHTML = `<path class="wiring-temp-path" d="${d}" fill="none"/>`;
}
// ── Edge waypoint dragging ──
private setEdgeWaypoint(edgeKey: string, x: number, y: number) {
const edge = this.edges.find(e => e.id === edgeKey);
if (edge) {
edge.waypoint = { x, y };
}
}
// ── Node CRUD ──
private addNode(type: CampaignNodeType, x: number, y: number) {
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
let data: any;
switch (type) {
case 'post':
data = { label: 'New Post', platform: 'x', postType: 'text', content: '', scheduledAt: '', status: 'draft', hashtags: [] };
break;
case 'thread':
data = { label: 'New Thread', threadId: '', tweetCount: 0, status: 'draft', preview: '' };
break;
case 'platform':
data = { label: 'Platform', platform: 'x', handle: '' };
break;
case 'audience':
data = { label: 'Audience', description: '', sizeEstimate: '' };
break;
case 'phase':
data = { label: 'New Phase', dateRange: '', color: '#6366f1', progress: 0, childNodeIds: [], size: { w: 400, h: 300 } };
break;
}
const node: CampaignPlannerNode = { id, type, position: { x, y }, data };
this.nodes.push(node);
this.drawCanvasContent();
this.selectedNodeId = id;
this.updateSelectionHighlight();
this.scheduleSave();
}
private deleteNode(id: string) {
this.nodes = this.nodes.filter(n => n.id !== id);
this.edges = this.edges.filter(e => e.from !== id && e.to !== id);
if (this.selectedNodeId === id) { this.selectedNodeId = null; this.exitInlineEdit(); }
if (this.inlineEditNodeId === id) this.exitInlineEdit();
this.drawCanvasContent();
this.scheduleSave();
}
private deleteEdge(edgeId: string) {
this.edges = this.edges.filter(e => e.id !== edgeId);
this.selectedEdgeKey = null;
this.redrawEdges();
this.scheduleSave();
}
// ── Inline config panel ──
private enterInlineEdit(nodeId: string) {
if (this.inlineEditNodeId && this.inlineEditNodeId !== nodeId) this.exitInlineEdit();
this.inlineEditNodeId = nodeId;
this.selectedNodeId = nodeId;
this.updateSelectionHighlight();
const node = this.nodes.find(n => n.id === nodeId);
if (!node || node.type === 'phase') return;
const nodeLayer = this.shadow.getElementById('node-layer');
const g = nodeLayer?.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null;
if (!g) return;
g.querySelector('.inline-edit-overlay')?.remove();
const s = getNodeSize(node);
const overlay = document.createElementNS('http://www.w3.org/2000/svg', 'g');
overlay.classList.add('inline-edit-overlay');
const panelW = 260;
const panelH = node.type === 'post' ? 340 : node.type === 'thread' ? 200 : 220;
const panelX = s.w + 12;
const panelY = 0;
const fo = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
fo.setAttribute('x', String(node.position.x + panelX));
fo.setAttribute('y', String(node.position.y + panelY));
fo.setAttribute('width', String(panelW));
fo.setAttribute('height', String(panelH));
const panelDiv = document.createElement('div');
panelDiv.className = 'cp-inline-config';
panelDiv.style.height = `${panelH}px`;
panelDiv.innerHTML = this.renderInlineConfigContent(node);
fo.appendChild(panelDiv);
overlay.appendChild(fo);
g.appendChild(overlay);
this.attachInlineConfigListeners(g, node);
// Click-outside handler
const clickOutside = (e: Event) => {
const target = e.target as Element;
if (!target.closest(`[data-node-id="${node.id}"]`)) {
this.exitInlineEdit();
this.shadow.removeEventListener('pointerdown', clickOutside, true);
}
};
setTimeout(() => {
this.shadow.addEventListener('pointerdown', clickOutside, true);
}, 100);
}
private exitInlineEdit() {
if (!this.inlineEditNodeId) return;
const nodeLayer = this.shadow.getElementById('node-layer');
const g = nodeLayer?.querySelector(`[data-node-id="${this.inlineEditNodeId}"]`);
g?.querySelector('.inline-edit-overlay')?.remove();
this.inlineEditNodeId = null;
}
private renderInlineConfigContent(node: CampaignPlannerNode): string {
const header = `<div class="cp-icp-header">${this.nodeIcon(node.type)} ${esc((node.data as any).label || node.type)}</div>`;
let body = '';
switch (node.type) {
case 'post': {
const d = node.data as PostNodeData;
body = `
<div class="cp-icp-body">
<label>Content</label>
<textarea data-field="content" rows="4">${esc(d.content)}</textarea>
<label>Platform</label>
<select data-field="platform">
${['x', 'linkedin', 'instagram', 'youtube', 'threads', 'bluesky'].map(p =>
`<option value="${p}" ${d.platform === p ? 'selected' : ''}>${p.charAt(0).toUpperCase() + p.slice(1)}</option>`
).join('')}
</select>
<label>Post Type</label>
<input data-field="postType" value="${esc(d.postType)}" placeholder="text, thread, carousel..."/>
<label>Scheduled</label>
<input type="datetime-local" data-field="scheduledAt" value="${d.scheduledAt}"/>
<label>Status</label>
<select data-field="status">
<option value="draft" ${d.status === 'draft' ? 'selected' : ''}>Draft</option>
<option value="scheduled" ${d.status === 'scheduled' ? 'selected' : ''}>Scheduled</option>
<option value="published" ${d.status === 'published' ? 'selected' : ''}>Published</option>
</select>
<label>Hashtags (comma-separated)</label>
<input data-field="hashtags" value="${esc(d.hashtags.join(', '))}"/>
</div>`;
break;
}
case 'thread': {
const d = node.data as ThreadNodeData;
body = `
<div class="cp-icp-body">
<label>Title</label>
<input data-field="label" value="${esc(d.label)}" readonly/>
<label>Tweet Count</label>
<input data-field="tweetCount" type="number" value="${d.tweetCount}"/>
<label>Status</label>
<select data-field="status">
<option value="draft" ${d.status === 'draft' ? 'selected' : ''}>Draft</option>
<option value="ready" ${d.status === 'ready' ? 'selected' : ''}>Ready</option>
<option value="published" ${d.status === 'published' ? 'selected' : ''}>Published</option>
</select>
<button class="cp-btn" data-action="open-thread" style="margin-top:8px;width:100%">Open in Thread Editor</button>
</div>`;
break;
}
case 'platform': {
const d = node.data as PlatformNodeData;
body = `
<div class="cp-icp-body">
<label>Platform</label>
<select data-field="platform">
${['x', 'linkedin', 'instagram', 'youtube', 'threads', 'bluesky'].map(p =>
`<option value="${p}" ${d.platform === p ? 'selected' : ''}>${p.charAt(0).toUpperCase() + p.slice(1)}</option>`
).join('')}
</select>
<label>Handle</label>
<input data-field="handle" value="${esc(d.handle)}" placeholder="@username"/>
</div>`;
break;
}
case 'audience': {
const d = node.data as AudienceNodeData;
body = `
<div class="cp-icp-body">
<label>Name</label>
<input data-field="label" value="${esc(d.label)}"/>
<label>Description</label>
<textarea data-field="description" rows="2">${esc(d.description)}</textarea>
<label>Size Estimate</label>
<input data-field="sizeEstimate" value="${esc(d.sizeEstimate)}" placeholder="e.g. ~50K"/>
</div>`;
break;
}
}
const toolbar = `
<div class="cp-icp-toolbar">
<button class="cp-icp-btn-done" data-action="done">Done</button>
<button class="cp-icp-btn-delete" data-action="delete">Delete</button>
</div>`;
return header + body + toolbar;
}
private attachInlineConfigListeners(g: SVGGElement, node: CampaignPlannerNode) {
const panel = g.querySelector('.cp-inline-config');
if (!panel) return;
// Field changes
panel.querySelectorAll('[data-field]').forEach(el => {
const field = el.getAttribute('data-field')!;
const handler = () => {
const val = (el as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).value;
if (field === 'hashtags') {
(node.data as PostNodeData).hashtags = val.split(',').map(h => h.trim()).filter(Boolean);
} else if (field === 'tweetCount') {
(node.data as ThreadNodeData).tweetCount = parseInt(val, 10) || 0;
} else {
(node.data as any)[field] = val;
}
// Update label for display
if (field === 'content' && node.type === 'post') {
(node.data as PostNodeData).label = val.split('\n')[0].substring(0, 40);
}
this.scheduleSave();
};
el.addEventListener('input', handler);
el.addEventListener('change', handler);
});
// Actions
panel.querySelectorAll('[data-action]').forEach(el => {
el.addEventListener('click', () => {
const action = el.getAttribute('data-action');
if (action === 'done') {
this.exitInlineEdit();
this.drawCanvasContent();
} else if (action === 'delete') {
this.exitInlineEdit();
this.deleteNode(node.id);
} else if (action === 'open-thread') {
const d = node.data as ThreadNodeData;
if (d.threadId) {
window.location.href = `${this.basePath}thread-editor/${d.threadId}/edit`;
}
}
});
});
}
private nodeIcon(type: CampaignNodeType): string {
switch (type) {
case 'post': return '<span style="font-size:14px">&#x1f4dd;</span>';
case 'thread': return '<span style="font-size:14px">&#x1f9f5;</span>';
case 'platform': return '<span style="font-size:14px">&#x1f4e1;</span>';
case 'audience': return '<span style="font-size:14px">&#x1f3af;</span>';
case 'phase': return '<span style="font-size:14px">&#x1f4c5;</span>';
default: return '';
}
}
// ── Context menu ──
private showContextMenu(screenX: number, screenY: number, canvasX: number, canvasY: number) {
this.contextMenuX = screenX;
this.contextMenuY = screenY;
this.contextMenuCanvasX = canvasX;
this.contextMenuCanvasY = canvasY;
this.contextMenuOpen = true;
let existing = this.shadow.getElementById('cp-context-menu');
if (existing) existing.remove();
const menu = document.createElement('div');
menu.id = 'cp-context-menu';
menu.className = 'cp-context-menu';
menu.style.left = `${screenX}px`;
menu.style.top = `${screenY}px`;
const items = [
{ icon: '\u{1f4dd}', label: 'Add Post', type: 'post' },
{ icon: '\u{1f9f5}', label: 'Add Thread', type: 'thread' },
{ icon: '\u{1f4e1}', label: 'Add Platform', type: 'platform' },
{ icon: '\u{1f3af}', label: 'Add Audience', type: 'audience' },
{ icon: '\u{1f4c5}', label: 'Add Phase', type: 'phase' },
];
menu.innerHTML = items.map(it =>
`<div class="cp-ctx-item" data-add-type="${it.type}">${it.icon} ${it.label}</div>`
).join('') + (this.selectedNodeId ? `
<div class="cp-ctx-sep"></div>
<div class="cp-ctx-item" data-action="delete-selected" style="color:#ef4444">\u{1f5d1} Delete Selected</div>
` : '');
const canvasArea = this.shadow.querySelector('.cp-canvas-area');
if (canvasArea) canvasArea.appendChild(menu);
menu.querySelectorAll('[data-add-type]').forEach(el => {
el.addEventListener('click', () => {
this.addNode(el.getAttribute('data-add-type') as CampaignNodeType, canvasX, canvasY);
this.closeContextMenu();
});
});
menu.querySelector('[data-action="delete-selected"]')?.addEventListener('click', () => {
if (this.selectedNodeId) this.deleteNode(this.selectedNodeId);
this.closeContextMenu();
});
// Close on next click
const close = () => { this.closeContextMenu(); document.removeEventListener('pointerdown', close); };
setTimeout(() => document.addEventListener('pointerdown', close), 50);
}
private closeContextMenu() {
this.contextMenuOpen = false;
this.shadow.getElementById('cp-context-menu')?.remove();
}
// ── View switching ──
private switchView(view: 'canvas' | 'timeline' | 'platform' | 'table') {
this.currentView = view;
const canvasEl = this.shadow.getElementById('cp-canvas');
const altEl = this.shadow.getElementById('cp-alt-view');
const zoomControls = this.shadow.querySelector('.cp-zoom-controls') as HTMLElement | null;
// Update active button
this.shadow.querySelectorAll('.cp-view-btn').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-view') === view);
});
if (view === 'canvas') {
if (canvasEl) canvasEl.style.display = '';
if (altEl) { altEl.innerHTML = ''; altEl.style.display = 'none'; }
if (zoomControls) zoomControls.style.display = '';
return;
}
// Hide canvas SVG (preserves state)
if (canvasEl) canvasEl.style.display = 'none';
if (zoomControls) zoomControls.style.display = 'none';
if (!altEl) return;
altEl.style.display = '';
switch (view) {
case 'timeline': altEl.innerHTML = this.renderTimeline(); break;
case 'platform': altEl.innerHTML = this.renderPlatformView(); break;
case 'table': altEl.innerHTML = this.renderTableView(); break;
}
this.attachAltViewListeners();
}
// ── Timeline view ──
private renderTimeline(): string {
const posts = this.nodes.filter(n => n.type === 'post' || n.type === 'thread');
const phases = this.nodes.filter(n => n.type === 'phase');
// Collect all dates; group posts by day
const dated = posts.map(n => {
const d = n.data as PostNodeData;
const dt = d.scheduledAt ? new Date(d.scheduledAt) : null;
return { node: n, date: dt };
}).sort((a, b) => {
if (!a.date && !b.date) return 0;
if (!a.date) return 1;
if (!b.date) return -1;
return a.date.getTime() - b.date.getTime();
});
// Build day buckets
const dayMap = new Map<string, typeof dated>();
for (const item of dated) {
const key = item.date ? item.date.toISOString().split('T')[0] : 'unscheduled';
if (!dayMap.has(key)) dayMap.set(key, []);
dayMap.get(key)!.push(item);
}
// Determine date range for columns
const allDates = dated.filter(d => d.date).map(d => d.date!);
if (allDates.length === 0) {
return '<div class="cp-timeline"><div style="padding:40px;color:#94a3b8;text-align:center">No scheduled posts yet. Add dates in canvas view.</div></div>';
}
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())));
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())));
minDate.setDate(minDate.getDate() - 1);
maxDate.setDate(maxDate.getDate() + 1);
const days: string[] = [];
const cur = new Date(minDate);
while (cur <= maxDate) {
days.push(cur.toISOString().split('T')[0]);
cur.setDate(cur.getDate() + 1);
}
// Phase bars
let phaseBars = '';
for (const phase of phases) {
const pd = phase.data as PhaseNodeData;
if (!pd.dateRange) continue;
const parts = pd.dateRange.split(/\s*[-]\s*/);
if (parts.length < 2) continue;
const start = days.indexOf(parts[0].trim());
const end = days.indexOf(parts[1].trim());
if (start < 0 || end < 0) continue;
phaseBars += `<div class="cp-tl-phase" style="grid-column:${start + 1}/${end + 2};background:${pd.color}22;border:1px solid ${pd.color}44">
<span style="color:${pd.color};font-weight:600;font-size:11px">${esc(pd.label)}</span>
</div>`;
}
// Day headers + cards
const headers = days.map(d => {
const dt = new Date(d + 'T12:00:00');
const label = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
return `<div class="cp-tl-day">${label}</div>`;
}).join('');
const columns = days.map(dayKey => {
const items = dayMap.get(dayKey) || [];
const cards = items.map(item => {
const d = item.node.data as PostNodeData;
const platform = d.platform || 'x';
const color = PLATFORM_COLORS[platform] || '#888';
const icon = PLATFORM_ICONS[platform] || platform.charAt(0);
const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b';
const time = item.date ? item.date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : '';
return `<div class="cp-tl-card" data-nav-node="${item.node.id}">
<span class="cp-tl-card__icon" style="color:${color}">${icon}</span>
<span class="cp-tl-card__label">${esc(d.label || (d.content || '').split('\n')[0].substring(0, 30) || 'Post')}</span>
<span class="cp-tl-card__dot" style="background:${statusColor}"></span>
${time ? `<span class="cp-tl-card__time">${time}</span>` : ''}
</div>`;
}).join('');
return `<div class="cp-tl-col">${cards}</div>`;
}).join('');
// Unscheduled section
let unscheduledHtml = '';
const unscheduled = dayMap.get('unscheduled');
if (unscheduled && unscheduled.length > 0) {
const cards = unscheduled.map(item => {
const d = item.node.data as PostNodeData;
const platform = d.platform || 'x';
const color = PLATFORM_COLORS[platform] || '#888';
const icon = PLATFORM_ICONS[platform] || platform.charAt(0);
return `<div class="cp-tl-card" data-nav-node="${item.node.id}">
<span class="cp-tl-card__icon" style="color:${color}">${icon}</span>
<span class="cp-tl-card__label">${esc(d.label || 'Post')}</span>
<span class="cp-tl-card__dot" style="background:#f59e0b"></span>
</div>`;
}).join('');
unscheduledHtml = `<div style="padding:12px 16px;border-top:1px solid #2d2d44">
<div style="font-size:11px;color:#94a3b8;margin-bottom:8px;font-weight:600">Unscheduled</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">${cards}</div>
</div>`;
}
return `<div class="cp-timeline">
<div class="cp-tl-scroll">
<div class="cp-tl-grid" style="grid-template-columns:repeat(${days.length},minmax(120px,1fr))">
<div class="cp-tl-header">${headers}</div>
<div class="cp-tl-phases" style="grid-column:1/-1">${phaseBars}</div>
<div class="cp-tl-body" style="grid-template-columns:repeat(${days.length},minmax(120px,1fr))">${columns}</div>
</div>
</div>
${unscheduledHtml}
</div>`;
}
// ── Platform view ──
private renderPlatformView(): string {
const posts = this.nodes.filter(n => n.type === 'post');
// Group by platform
const platformMap = new Map<string, CampaignPlannerNode[]>();
for (const node of posts) {
const d = node.data as PostNodeData;
const p = d.platform || 'x';
if (!platformMap.has(p)) platformMap.set(p, []);
platformMap.get(p)!.push(node);
}
// Sort each platform's posts by date
platformMap.forEach(items => {
items.sort((a, b) => {
const da = (a.data as PostNodeData).scheduledAt;
const db = (b.data as PostNodeData).scheduledAt;
if (!da && !db) return 0;
if (!da) return 1;
if (!db) return -1;
return new Date(da).getTime() - new Date(db).getTime();
});
});
if (platformMap.size === 0) {
return '<div class="cp-platform-view"><div style="padding:40px;color:#94a3b8;text-align:center">No posts yet. Add posts in canvas view.</div></div>';
}
const columns = Array.from(platformMap.entries()).map(([platform, items]) => {
const color = PLATFORM_COLORS[platform] || '#888';
const icon = PLATFORM_ICONS[platform] || platform.charAt(0);
const cards = items.map(node => {
const d = node.data as PostNodeData;
const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b';
const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'Unscheduled';
const preview = (d.content || '').split('\n')[0].substring(0, 60);
const tags = d.hashtags.slice(0, 3).map(h =>
`<span class="cp-pv-tag">${esc(h.startsWith('#') ? h : '#' + h)}</span>`
).join('');
return `<div class="cp-pv-card" data-nav-node="${node.id}">
<div class="cp-pv-card__header">
<span class="cp-pv-card__label">${esc(d.label || preview || 'Post')}</span>
<span class="cp-pv-card__dot" style="background:${statusColor}" title="${d.status}"></span>
</div>
${preview ? `<div class="cp-pv-card__preview">${esc(preview)}</div>` : ''}
<div class="cp-pv-card__meta">
<span class="cp-pv-card__type">${esc(d.postType)}</span>
<span class="cp-pv-card__date">${dateStr}</span>
</div>
${tags ? `<div class="cp-pv-card__tags">${tags}</div>` : ''}
</div>`;
}).join('');
return `<div class="cp-pv-column">
<div class="cp-pv-column__header" style="border-top:3px solid ${color}">
<span style="color:${color};font-size:14px">${icon}</span>
<span class="cp-pv-column__title">${platform.charAt(0).toUpperCase() + platform.slice(1)}</span>
<span class="cp-pv-column__count">${items.length}</span>
</div>
<div class="cp-pv-column__body">${cards}</div>
</div>`;
}).join('');
return `<div class="cp-platform-view">${columns}</div>`;
}
// ── Table view ──
private renderTableView(): string {
const posts = this.nodes.filter(n => n.type === 'post' || n.type === 'thread');
posts.sort((a, b) => {
const da = (a.data as PostNodeData).scheduledAt || '';
const db = (b.data as PostNodeData).scheduledAt || '';
if (!da && !db) return 0;
if (!da) return 1;
if (!db) return -1;
return da.localeCompare(db);
});
if (posts.length === 0) {
return '<div class="cp-table-view"><div style="padding:40px;color:#94a3b8;text-align:center">No posts yet.</div></div>';
}
const rows = posts.map(node => {
const d = node.data as PostNodeData;
const platform = d.platform || 'x';
const color = PLATFORM_COLORS[platform] || '#888';
const icon = PLATFORM_ICONS[platform] || platform.charAt(0);
const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b';
const statusLabel = d.status ? d.status.charAt(0).toUpperCase() + d.status.slice(1) : 'Draft';
const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
const preview = (d.content || '').split('\n')[0].substring(0, 50);
const tags = (d.hashtags || []).slice(0, 3).map(h =>
`<span class="cp-tv-tag">${esc(h.startsWith('#') ? h : '#' + h)}</span>`
).join('');
return `<tr class="cp-tv-row" data-nav-node="${node.id}">
<td><span class="cp-tv-dot" style="background:${statusColor}"></span> ${statusLabel}</td>
<td><span style="color:${color};margin-right:4px">${icon}</span> ${platform.charAt(0).toUpperCase() + platform.slice(1)}</td>
<td>${esc(d.postType || node.type)}</td>
<td class="cp-tv-content">${esc(preview)}</td>
<td>${dateStr}</td>
<td>${tags}</td>
</tr>`;
}).join('');
return `<div class="cp-table-view">
<table class="cp-tv-table">
<thead><tr>
<th>Status</th><th>Platform</th><th>Type</th><th>Content</th><th>Scheduled</th><th>Hashtags</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
// ── Alt view click-to-navigate ──
private attachAltViewListeners() {
const altEl = this.shadow.getElementById('cp-alt-view');
if (!altEl) return;
altEl.querySelectorAll('[data-nav-node]').forEach(el => {
el.addEventListener('click', () => {
const nodeId = el.getAttribute('data-nav-node');
if (!nodeId) return;
this.navigateToCanvasNode(nodeId);
});
});
}
private navigateToCanvasNode(nodeId: string) {
this.switchView('canvas');
const node = this.nodes.find(n => n.id === nodeId);
if (!node) return;
this.selectedNodeId = nodeId;
this.updateSelectionHighlight();
// Center node in view
const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
if (svg) {
const rect = svg.getBoundingClientRect();
const s = getNodeSize(node);
const cx = node.position.x + s.w / 2;
const cy = node.position.y + s.h / 2;
this.canvasPanX = rect.width / 2 - cx * this.canvasZoom;
this.canvasPanY = rect.height / 2 - cy * this.canvasZoom;
this.updateCanvasTransform();
}
this.enterInlineEdit(nodeId);
}
// ── Rendering ──
private render() {
const schedulerUrl = 'https://demo.rsocials.online';
this.shadow.innerHTML = `
<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css">
<div class="cp-root">
<div class="cp-toolbar">
<span class="cp-toolbar__title">
&#x1f4e2; ${esc(this.flowName || 'Campaign Planner')}
${this.space === 'demo' ? '<span class="cp-demo-badge">Demo</span>' : ''}
</span>
<div class="cp-view-switcher">
<button class="cp-view-btn ${this.currentView === 'canvas' ? 'active' : ''}" data-view="canvas" title="Canvas">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="4" cy="4" r="2" fill="currentColor"/><circle cx="12" cy="4" r="2" fill="currentColor"/><circle cx="4" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="12" r="2" fill="currentColor"/><line x1="6" y1="4" x2="10" y2="4" stroke="currentColor" stroke-width="1.2"/><line x1="4" y1="6" x2="4" y2="10" stroke="currentColor" stroke-width="1.2"/><line x1="6" y1="12" x2="10" y2="12" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
<button class="cp-view-btn ${this.currentView === 'timeline' ? 'active' : ''}" data-view="timeline" title="Timeline">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="3" width="4" height="10" rx="1" fill="currentColor" opacity="0.3"/><rect x="6" y="1" width="4" height="14" rx="1" fill="currentColor" opacity="0.5"/><rect x="11" y="5" width="4" height="8" rx="1" fill="currentColor" opacity="0.7"/></svg>
</button>
<button class="cp-view-btn ${this.currentView === 'platform' ? 'active' : ''}" data-view="platform" title="Platform">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="4" height="14" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="6" y="1" width="4" height="10" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="11" y="1" width="4" height="12" rx="1" stroke="currentColor" stroke-width="1.2"/></svg>
</button>
<button class="cp-view-btn ${this.currentView === 'table' ? 'active' : ''}" data-view="table" title="Table">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="14" height="3" rx="0.5" fill="currentColor" opacity="0.5"/><rect x="1" y="5.5" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/><rect x="1" y="9" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/><rect x="1" y="12.5" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/></svg>
</button>
</div>
<div class="cp-toolbar__actions">
<button class="cp-btn cp-btn--add" id="add-post">+ Post</button>
<button class="cp-btn cp-btn--add" id="add-platform">+ Platform</button>
<button class="cp-btn cp-btn--add" id="add-audience">+ Audience</button>
<button class="cp-btn cp-btn--postiz" id="toggle-postiz">Schedule in Postiz</button>
</div>
</div>
<div class="cp-canvas-area">
<div class="cp-canvas" id="cp-canvas">
<svg id="cp-svg" width="100%" height="100%">
<defs>
<marker id="arrow-sequence" viewBox="0 0 10 6" refX="9" refY="3" markerWidth="8" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 3 L 0 6 z" fill="#8b5cf6"/>
</marker>
</defs>
<g id="canvas-transform" transform="translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})">
<g id="edge-layer">${this.renderAllEdges()}</g>
<g id="node-layer">${this.renderAllNodes()}</g>
<g id="wire-layer"></g>
</g>
</svg>
<div class="cp-zoom-controls">
<button class="cp-zoom-btn" id="zoom-in" title="Zoom in">+</button>
<span class="cp-zoom-level" id="zoom-level">${Math.round(this.canvasZoom * 100)}%</span>
<button class="cp-zoom-btn" id="zoom-out" title="Zoom out">&minus;</button>
<button class="cp-zoom-btn" id="zoom-fit" title="Fit to view (F)">&#x2922;</button>
</div>
</div>
<div id="cp-alt-view" class="cp-alt-view"></div>
<div class="cp-postiz-panel ${this.postizOpen ? 'open' : ''}" id="postiz-panel">
<div class="cp-postiz-header">
<span class="cp-postiz-title">Postiz \u2014 Post Scheduler</span>
<button class="cp-postiz-close" id="close-postiz">\u2715</button>
</div>
<iframe class="cp-postiz-iframe" src="${this.postizOpen ? schedulerUrl : 'about:blank'}" title="Postiz"></iframe>
</div>
</div>
</div>
`;
this.attachListeners();
}
// ── Node rendering ──
private renderAllNodes(): string {
// Phases first (rendered behind)
const phases = this.nodes.filter(n => n.type === 'phase').map(n => this.renderPhaseNode(n));
const others = this.nodes.filter(n => n.type !== 'phase').map(n => this.renderContentNode(n));
return phases.join('') + others.join('');
}
private renderPhaseNode(node: CampaignPlannerNode): string {
const d = node.data as PhaseNodeData;
const { x, y } = node.position;
const { w, h } = getNodeSize(node);
const progress = d.progress || 0;
const progressW = Math.max(0, Math.min(1, progress / 100)) * (w - 32);
return `
<g class="cp-node ${this.selectedNodeId === node.id ? 'selected' : ''}" data-node-id="${node.id}" data-node-type="phase">
<rect class="cp-phase-rect" x="${x}" y="${y}" width="${w}" height="${h}"
fill="${d.color}0a" stroke="${d.color}44"/>
<text class="cp-phase-label" x="${x + 16}" y="${y + 24}">${esc(d.label)}</text>
<text class="cp-phase-date" x="${x + 16}" y="${y + 40}">${esc(d.dateRange)}</text>
<rect class="cp-phase-progress-bg" x="${x + 16}" y="${y + 50}" width="${w - 32}" height="4"/>
<rect class="cp-phase-progress-fill" x="${x + 16}" y="${y + 50}" width="${progressW}" height="4" fill="${d.color}"/>
</g>`;
}
private renderContentNode(node: CampaignPlannerNode): string {
const { x, y } = node.position;
const s = getNodeSize(node);
let inner = '';
switch (node.type) {
case 'post': inner = this.renderPostNodeInner(node); break;
case 'thread': inner = this.renderThreadNodeInner(node); break;
case 'platform': inner = this.renderPlatformNodeInner(node); break;
case 'audience': inner = this.renderAudienceNodeInner(node); break;
}
const ports = this.renderPortsSvg(node);
return `
<g class="cp-node ${this.selectedNodeId === node.id ? 'selected' : ''}" data-node-id="${node.id}" data-node-type="${node.type}">
<foreignObject x="${x}" y="${y}" width="${s.w}" height="${s.h}">
${inner}
</foreignObject>
${ports}
</g>`;
}
private renderPostNodeInner(node: CampaignPlannerNode): string {
const d = node.data as PostNodeData;
const platform = d.platform || 'x';
const color = PLATFORM_COLORS[platform] || '#888';
const icon = PLATFORM_ICONS[platform] || platform.charAt(0);
const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b';
const preview = (d.content || '').split('\n')[0].substring(0, 50);
const charCount = (d.content || '').length;
const charMax = platform === 'x' ? 280 : 2200;
const charPct = Math.min(1, charCount / charMax) * 100;
const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : 'Unscheduled';
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#1a1a2e;border:1px solid #2d2d44;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;display:flex">
<div style="width:4px;background:${color};flex-shrink:0"></div>
<div style="flex:1;padding:10px 12px;display:flex;flex-direction:column;gap:4px;min-width:0">
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:12px;width:20px;height:20px;border-radius:4px;background:${color}22;color:${color};display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0">${icon}</span>
<span style="font-size:12px;font-weight:600;color:#e2e8f0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.label || preview || 'New Post')}</span>
<span style="width:7px;height:7px;border-radius:50%;background:${statusColor};flex-shrink:0" title="${d.status}"></span>
</div>
<div style="font-size:10px;color:#94a3b8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(preview)}</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:auto">
<div style="flex:1;height:3px;background:#1e293b;border-radius:2px;overflow:hidden">
<div style="width:${charPct}%;height:100%;background:${charPct > 90 ? '#ef4444' : color};border-radius:2px"></div>
</div>
<span style="font-size:9px;color:#64748b">${charCount}/${charMax}</span>
</div>
<div style="font-size:9px;color:#64748b">${dateStr}</div>
</div>
</div>`;
}
private renderThreadNodeInner(node: CampaignPlannerNode): string {
const d = node.data as ThreadNodeData;
const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'ready' ? '#3b82f6' : '#f59e0b';
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#1a1a2e;border:1px solid #2d2d44;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:10px 14px;display:flex;flex-direction:column;gap:6px">
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:14px">&#x1f9f5;</span>
<span style="font-size:12px;font-weight:600;color:#e2e8f0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.label || 'Thread')}</span>
<span style="width:7px;height:7px;border-radius:50%;background:${statusColor};flex-shrink:0" title="${d.status}"></span>
</div>
<div style="font-size:10px;color:#94a3b8">${d.tweetCount} tweet${d.tweetCount === 1 ? '' : 's'}</div>
<div style="font-size:10px;color:#64748b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.preview || '')}</div>
</div>`;
}
private renderPlatformNodeInner(node: CampaignPlannerNode): string {
const d = node.data as PlatformNodeData;
const platform = d.platform || 'x';
const color = PLATFORM_COLORS[platform] || '#888';
const icon = PLATFORM_ICONS[platform] || platform.charAt(0);
const connectedCount = this.edges.filter(e => e.to === node.id && e.type === 'publish').length;
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:${color}0a;border:1px solid ${color}33;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:12px 14px;display:flex;flex-direction:column;gap:4px">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:18px;width:30px;height:30px;border-radius:6px;background:${color}22;color:${color};display:flex;align-items:center;justify-content:center;font-weight:700">${icon}</span>
<div>
<div style="font-size:12px;font-weight:600;color:#e2e8f0">${esc(d.label)}</div>
<div style="font-size:10px;color:#94a3b8">${esc(d.handle)}</div>
</div>
</div>
<div style="font-size:9px;color:#64748b;margin-top:auto">${connectedCount} post${connectedCount === 1 ? '' : 's'} connected</div>
</div>`;
}
private renderAudienceNodeInner(node: CampaignPlannerNode): string {
const d = node.data as AudienceNodeData;
const connectedCount = this.edges.filter(e => e.to === node.id && e.type === 'target').length;
return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#f59e0b0a;border:1px solid #f59e0b33;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:12px 14px;display:flex;flex-direction:column;gap:4px">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:16px">&#x1f3af;</span>
<div>
<div style="font-size:12px;font-weight:600;color:#e2e8f0">${esc(d.label)}</div>
<div style="font-size:10px;color:#94a3b8">${esc(d.sizeEstimate)}</div>
</div>
</div>
<div style="font-size:9px;color:#64748b">${connectedCount} targeted \u00b7 ${esc(d.description).substring(0, 40)}</div>
</div>`;
}
// ── Port rendering ──
private renderPortsSvg(node: CampaignPlannerNode): string {
const s = getNodeSize(node);
const defs = getPortDefs(node.type);
return defs.map(p => {
const cx = node.position.x + s.w * p.xFrac;
const cy = node.position.y + s.h * p.yFrac;
const arrow = this.portArrowPath(cx, cy, p);
return `<g class="port-group" data-port-kind="${p.kind}" data-port-dir="${p.dir}" data-node-id="${node.id}">
<circle class="port-hit" cx="${cx}" cy="${cy}" r="12" fill="transparent"/>
<circle class="port-dot" cx="${cx}" cy="${cy}" r="5" style="fill:${p.color};color:${p.color}"/>
<path class="port-arrow" d="${arrow}" style="fill:${p.color}" opacity="0.7"/>
</g>`;
}).join('');
}
// ── Edge rendering ──
private renderAllEdges(): string {
return this.edges.map(edge => this.renderEdge(edge)).join('');
}
private renderEdge(edge: CampaignEdge): string {
const fromNode = this.nodes.find(n => n.id === edge.from);
const toNode = this.nodes.find(n => n.id === edge.to);
if (!fromNode || !toNode) return '';
// Determine source/target port positions
let sourcePortKind: string;
let targetPortKind: string;
switch (edge.type) {
case 'publish':
sourcePortKind = 'publish';
targetPortKind = 'content-in';
break;
case 'sequence':
sourcePortKind = 'sequence-out';
targetPortKind = 'sequence-in';
break;
case 'target':
sourcePortKind = 'publish';
targetPortKind = 'target-in';
break;
default:
sourcePortKind = 'publish';
targetPortKind = 'content-in';
}
const p1 = getPortPosition(fromNode, sourcePortKind);
const p2 = getPortPosition(toNode, targetPortKind);
if (!p1 || !p2) return '';
const color = EDGE_COLORS[edge.type] || '#888';
const style = EDGE_STYLES[edge.type] || { width: 2, dash: '', animated: false };
const cssClass = edge.type === 'publish' ? 'edge-path-publish'
: edge.type === 'sequence' ? 'edge-path-sequence'
: 'edge-path-target';
// Build bezier path
let d: string;
let midX: number;
let midY: number;
if (edge.waypoint) {
const cx1 = (4 * edge.waypoint.x - p1.x - p2.x) / 3;
const cy1 = (4 * edge.waypoint.y - p1.y - p2.y) / 3;
const c1x = p1.x + (cx1 - p1.x) * 0.8;
const c1y = p1.y + (cy1 - p1.y) * 0.8;
const c2x = p2.x + (cx1 - p2.x) * 0.8;
const c2y = p2.y + (cy1 - p2.y) * 0.8;
d = `M ${p1.x} ${p1.y} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
midX = edge.waypoint.x;
midY = edge.waypoint.y;
} else {
// Horizontal-biased for publish/target (right → left), vertical for sequence (bottom → top)
if (edge.type === 'sequence') {
const cy1 = p1.y + (p2.y - p1.y) * 0.4;
const cy2 = p1.y + (p2.y - p1.y) * 0.6;
d = `M ${p1.x} ${p1.y} C ${p1.x} ${cy1}, ${p2.x} ${cy2}, ${p2.x} ${p2.y}`;
} else {
const dx = p2.x - p1.x;
const cx1 = p1.x + dx * 0.5;
const cx2 = p2.x - dx * 0.5;
d = `M ${p1.x} ${p1.y} C ${cx1} ${p1.y}, ${cx2} ${p2.y}, ${p2.x} ${p2.y}`;
}
midX = (p1.x + p2.x) / 2;
midY = (p1.y + p2.y) / 2;
}
const markerEnd = edge.type === 'sequence' ? ' marker-end="url(#arrow-sequence)"' : '';
const dashAttr = style.dash ? ` stroke-dasharray="${style.dash}"` : '';
const selected = this.selectedEdgeKey === edge.id ? ' stroke-opacity="1"' : '';
return `<g class="edge-group" data-edge-id="${edge.id}">
<path d="${d}" fill="none" stroke="transparent" stroke-width="14" class="edge-hit-area" style="cursor:pointer"/>
<path d="${d}" fill="none" stroke="${color}" stroke-width="${style.width * 2.5}" stroke-opacity="0.08" class="edge-glow"/>
<path d="${d}" fill="none" stroke="${color}" stroke-width="${style.width}"${dashAttr} class="${cssClass}" style="--edge-color:${color}"${markerEnd}${selected}/>
<circle cx="${midX}" cy="${midY}" r="4" class="edge-drag-handle" data-edge-drag="${edge.id}"/>
</g>`;
}
// ── Event listeners ──
private attachListeners() {
const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
const canvas = this.shadow.getElementById('cp-canvas');
if (!svg || !canvas) return;
// View switcher buttons
this.shadow.querySelectorAll('.cp-view-btn').forEach(btn => {
btn.addEventListener('click', () => {
const view = btn.getAttribute('data-view') as 'canvas' | 'timeline' | 'platform' | 'table';
if (view) this.switchView(view);
});
});
// Toolbar add buttons
this.shadow.getElementById('add-post')?.addEventListener('click', () => {
const rect = svg.getBoundingClientRect();
const cx = (rect.width / 2 - this.canvasPanX) / this.canvasZoom;
const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom;
this.addNode('post', cx, cy);
});
this.shadow.getElementById('add-platform')?.addEventListener('click', () => {
const rect = svg.getBoundingClientRect();
const cx = (rect.width / 2 - this.canvasPanX) / this.canvasZoom;
const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom;
this.addNode('platform', cx, cy);
});
this.shadow.getElementById('add-audience')?.addEventListener('click', () => {
const rect = svg.getBoundingClientRect();
const cx = (rect.width / 2 - this.canvasPanX) / this.canvasZoom;
const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom;
this.addNode('audience', cx, cy);
});
// Postiz panel
this.shadow.getElementById('toggle-postiz')?.addEventListener('click', () => {
this.postizOpen = !this.postizOpen;
const panel = this.shadow.getElementById('postiz-panel');
const iframe = panel?.querySelector('iframe');
if (panel) panel.classList.toggle('open', this.postizOpen);
if (iframe && this.postizOpen) iframe.src = 'https://demo.rsocials.online';
if (iframe && !this.postizOpen) iframe.src = 'about:blank';
});
this.shadow.getElementById('close-postiz')?.addEventListener('click', () => {
this.postizOpen = false;
const panel = this.shadow.getElementById('postiz-panel');
const iframe = panel?.querySelector('iframe');
if (panel) panel.classList.remove('open');
if (iframe) iframe.src = 'about:blank';
});
// Zoom controls
this.shadow.getElementById('zoom-in')?.addEventListener('click', () => {
const rect = svg.getBoundingClientRect();
this.zoomAt(rect.width / 2, rect.height / 2, 1.25);
});
this.shadow.getElementById('zoom-out')?.addEventListener('click', () => {
const rect = svg.getBoundingClientRect();
this.zoomAt(rect.width / 2, rect.height / 2, 0.8);
});
this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView());
// Wheel zoom
svg.addEventListener('wheel', (e: WheelEvent) => {
e.preventDefault();
const rect = svg.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const factor = 1 - e.deltaY * 0.003;
this.zoomAt(mx, my, factor);
}, { passive: false });
// Context menu
svg.addEventListener('contextmenu', (e: MouseEvent) => {
e.preventDefault();
const rect = svg.getBoundingClientRect();
const canvasX = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
const canvasY = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
this.showContextMenu(e.clientX - rect.left, e.clientY - rect.top, canvasX, canvasY);
});
// Pointer down — start interactions
const DRAG_THRESHOLD = 5;
svg.addEventListener('pointerdown', (e: PointerEvent) => {
if (e.button === 2) return; // right-click handled by contextmenu
if (e.button !== 0) return;
this.closeContextMenu();
const target = e.target as Element;
// Edge drag handle?
const edgeDragEl = target.closest('[data-edge-drag]');
if (edgeDragEl) {
this.draggingEdgeKey = edgeDragEl.getAttribute('data-edge-drag');
e.preventDefault();
return;
}
// Edge hit area? → select edge
const edgeGroup = target.closest('.edge-group');
if (edgeGroup && target.classList.contains('edge-hit-area')) {
this.selectedEdgeKey = edgeGroup.getAttribute('data-edge-id');
this.selectedNodeId = null;
this.exitInlineEdit();
this.updateSelectionHighlight();
this.redrawEdges();
e.preventDefault();
return;
}
// Port click → wiring
const portGroup = target.closest('.port-group');
if (portGroup) {
const nodeId = portGroup.getAttribute('data-node-id');
const portKind = portGroup.getAttribute('data-port-kind');
const portDir = portGroup.getAttribute('data-port-dir');
if (this.wiringActive) {
// Try to complete wiring
if (portDir === 'in' && nodeId && nodeId !== this.wiringSourceNodeId) {
this.completeWiring(nodeId);
} else {
this.cancelWiring();
}
} else if (portDir === 'out' && nodeId && portKind) {
this.enterWiring(nodeId, portKind);
}
e.preventDefault();
return;
}
// Node drag
const nodeGroup = target.closest('[data-node-id]');
if (nodeGroup) {
// If wiring, check if clicking on a valid target node
if (this.wiringActive) {
const nodeId = nodeGroup.getAttribute('data-node-id');
if (nodeId && nodeId !== this.wiringSourceNodeId) {
const targetNode = this.nodes.find(n => n.id === nodeId);
if (targetNode) {
const targetPort = this.getWiringTargetPort(targetNode);
if (targetPort) {
this.completeWiring(nodeId);
e.preventDefault();
return;
}
}
}
this.cancelWiring();
e.preventDefault();
return;
}
const nodeId = nodeGroup.getAttribute('data-node-id')!;
const node = this.nodes.find(n => n.id === nodeId);
if (node) {
this.draggingNodeId = nodeId;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragNodeStartX = node.position.x;
this.dragNodeStartY = node.position.y;
this.nodeDragStarted = false;
}
e.preventDefault();
return;
}
// Canvas pan (clicked on background)
if (this.wiringActive) { this.cancelWiring(); }
this.isPanning = true;
this.panStartX = e.clientX;
this.panStartY = e.clientY;
this.panStartPanX = this.canvasPanX;
this.panStartPanY = this.canvasPanY;
canvas.classList.add('grabbing');
e.preventDefault();
});
// Global pointer move/up
this._boundPointerMove = (e: PointerEvent) => {
// Wiring drag
if (this.wiringActive) {
this.wiringDragging = true;
this.wiringPointerX = e.clientX;
this.wiringPointerY = e.clientY;
this.updateWiringTempLine();
return;
}
// Edge waypoint drag
if (this.draggingEdgeKey) {
const rect = svg.getBoundingClientRect();
const canvasX = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
const canvasY = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
this.setEdgeWaypoint(this.draggingEdgeKey, canvasX, canvasY);
this.redrawEdges();
return;
}
// Pan
if (this.isPanning) {
this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
this.updateCanvasTransform();
return;
}
// Node drag
if (this.draggingNodeId) {
const rawDx = e.clientX - this.dragStartX;
const rawDy = e.clientY - this.dragStartY;
if (!this.nodeDragStarted) {
if (Math.abs(rawDx) < DRAG_THRESHOLD && Math.abs(rawDy) < DRAG_THRESHOLD) return;
this.nodeDragStarted = true;
}
const dx = rawDx / this.canvasZoom;
const dy = rawDy / this.canvasZoom;
const node = this.nodes.find(n => n.id === this.draggingNodeId);
if (node) {
node.position.x = this.dragNodeStartX + dx;
node.position.y = this.dragNodeStartY + dy;
this.updateNodePosition(node);
this.redrawEdges();
}
}
};
this._boundPointerUp = (e: PointerEvent) => {
// Edge drag end
if (this.draggingEdgeKey) {
this.draggingEdgeKey = null;
this.scheduleSave();
return;
}
// Pan end
if (this.isPanning) {
this.isPanning = false;
canvas.classList.remove('grabbing');
return;
}
// Node drag/click end
if (this.draggingNodeId) {
const clickedNodeId = this.draggingNodeId;
const wasDragged = this.nodeDragStarted;
this.draggingNodeId = null;
this.nodeDragStarted = false;
if (!wasDragged) {
// Click → select + open inline editor
this.selectedNodeId = clickedNodeId;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
this.redrawEdges();
this.enterInlineEdit(clickedNodeId);
} else {
this.scheduleSave();
}
}
};
document.addEventListener('pointermove', this._boundPointerMove);
document.addEventListener('pointerup', this._boundPointerUp);
// Double-click → thread navigation
svg.addEventListener('dblclick', (e: MouseEvent) => {
const target = e.target as Element;
const nodeGroup = target.closest('[data-node-id]');
if (!nodeGroup) return;
const nodeId = nodeGroup.getAttribute('data-node-id')!;
const node = this.nodes.find(n => n.id === nodeId);
if (node?.type === 'thread') {
const d = node.data as ThreadNodeData;
if (d.threadId) {
window.location.href = `${this.basePath}thread-editor/${d.threadId}/edit`;
}
}
});
// Keyboard shortcuts
this._boundKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input/textarea
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
// Escape: in alt views → switch back to canvas
if (e.key === 'Escape') {
if (this.currentView !== 'canvas') {
this.switchView('canvas');
return;
}
if (this.wiringActive) this.cancelWiring();
else if (this.inlineEditNodeId) this.exitInlineEdit();
else if (this.selectedNodeId) { this.selectedNodeId = null; this.updateSelectionHighlight(); }
else if (this.selectedEdgeKey) { this.selectedEdgeKey = null; this.redrawEdges(); }
return;
}
// Canvas-only shortcuts
if (this.currentView !== 'canvas') return;
if (e.key === 'Delete' || e.key === 'Backspace') {
if (this.selectedNodeId) {
this.deleteNode(this.selectedNodeId);
} else if (this.selectedEdgeKey) {
this.deleteEdge(this.selectedEdgeKey);
}
} else if (e.key === 'f' || e.key === 'F') {
this.fitView();
} else if (e.key === '+' || e.key === '=') {
const rect = svg.getBoundingClientRect();
this.zoomAt(rect.width / 2, rect.height / 2, 1.15);
} else if (e.key === '-' || e.key === '_') {
const rect = svg.getBoundingClientRect();
this.zoomAt(rect.width / 2, rect.height / 2, 0.87);
}
};
document.addEventListener('keydown', this._boundKeyDown);
// Touch gestures
const getTouchCenter = (touches: TouchList) => ({
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
});
const getTouchDist = (touches: TouchList) =>
Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY);
svg.addEventListener('touchstart', (e: TouchEvent) => {
if (e.touches.length === 2) {
e.preventDefault();
this.isTouchPanning = true;
this.isPanning = false;
if (this.draggingNodeId) { this.draggingNodeId = null; this.nodeDragStarted = false; }
if (this.wiringActive) this.cancelWiring();
this.lastTouchCenter = getTouchCenter(e.touches);
this.lastTouchDist = getTouchDist(e.touches);
}
}, { passive: false });
svg.addEventListener('touchmove', (e: TouchEvent) => {
if (e.touches.length === 2 && this.isTouchPanning) {
e.preventDefault();
const center = getTouchCenter(e.touches);
const dist = getTouchDist(e.touches);
if (this.lastTouchCenter) {
this.canvasPanX += center.x - this.lastTouchCenter.x;
this.canvasPanY += center.y - this.lastTouchCenter.y;
}
if (this.lastTouchDist && this.lastTouchDist > 0) {
const zoomDelta = dist / this.lastTouchDist;
const newZoom = Math.max(0.1, Math.min(4, this.canvasZoom * zoomDelta));
const rect = svg.getBoundingClientRect();
const cx = center.x - rect.left;
const cy = center.y - rect.top;
this.canvasPanX = cx - (cx - this.canvasPanX) * (newZoom / this.canvasZoom);
this.canvasPanY = cy - (cy - this.canvasPanY) * (newZoom / this.canvasZoom);
this.canvasZoom = newZoom;
}
this.lastTouchCenter = center;
this.lastTouchDist = dist;
this.updateCanvasTransform();
}
}, { passive: false });
svg.addEventListener('touchend', (e: TouchEvent) => {
if (e.touches.length < 2) {
this.lastTouchCenter = null;
this.lastTouchDist = null;
this.isTouchPanning = false;
}
});
}
}
customElements.define('folk-campaign-planner', FolkCampaignPlanner);