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

1553 lines
56 KiB
TypeScript

/**
* <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 = '';
// 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;
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 = `<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 Builder</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.space}/rsocials/thread/${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();
}
// ── 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-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 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;
// 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);