/** * — 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 = { 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 = { 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 = { 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 = ''; // 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 | 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; 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 })); this.drawCanvasContent(); } }); 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) 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() { 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) 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 = 60; 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.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(); } 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 = ``; } // ── 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 = `
${this.nodeIcon(node.type)} ${esc((node.data as any).label || node.type)}
`; let body = ''; switch (node.type) { case 'post': { const d = node.data as PostNodeData; body = `
`; break; } case 'thread': { const d = node.data as ThreadNodeData; body = `
`; break; } case 'platform': { const d = node.data as PlatformNodeData; body = `
`; break; } case 'audience': { const d = node.data as AudienceNodeData; body = `
`; break; } } const toolbar = `
`; 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.space}/rsocials/thread/${d.threadId}/edit`; } } }); }); } private nodeIcon(type: CampaignNodeType): string { switch (type) { case 'post': return '📝'; case 'thread': return '🧵'; case 'platform': return '📡'; case 'audience': return '🎯'; case 'phase': return '📅'; 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 => `
${it.icon} ${it.label}
` ).join('') + (this.selectedNodeId ? `
\u{1f5d1} Delete Selected
` : ''); 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(); } // ── Rendering ── private render() { const schedulerUrl = 'https://demo.rsocials.online'; this.shadow.innerHTML = `
📢 ${esc(this.flowName || 'Campaign Planner')} ${this.space === 'demo' ? 'Demo' : ''}
${this.renderAllEdges()} ${this.renderAllNodes()}
${Math.round(this.canvasZoom * 100)}%
Postiz \u2014 Post Scheduler
`; 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 ` ${esc(d.label)} ${esc(d.dateRange)} `; } 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 ` ${inner} ${ports} `; } 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 `
${icon} ${esc(d.label || preview || 'New Post')}
${esc(preview)}
${charCount}/${charMax}
${dateStr}
`; } private renderThreadNodeInner(node: CampaignPlannerNode): string { const d = node.data as ThreadNodeData; const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'ready' ? '#3b82f6' : '#f59e0b'; return `
🧵 ${esc(d.label || 'Thread')}
${d.tweetCount} tweet${d.tweetCount === 1 ? '' : 's'}
${esc(d.preview || '')}
`; } 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 `
${icon}
${esc(d.label)}
${esc(d.handle)}
${connectedCount} post${connectedCount === 1 ? '' : 's'} connected
`; } 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 `
🎯
${esc(d.label)}
${esc(d.sizeEstimate)}
${connectedCount} targeted \u00b7 ${esc(d.description).substring(0, 40)}
`; } // ── 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 ` `; }).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 ` `; } // ── 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; // 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.space}/rsocials/thread/${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; 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 === 'Escape') { 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(); } } 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);