1553 lines
56 KiB
TypeScript
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">📝</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();
|
|
}
|
|
|
|
// ── 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-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 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;
|
|
|
|
// 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);
|