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