// 出張・旅程ジェネレーター — Top-level App function App(){ const initial = { title: '', subtitle: '', showTitleOnCanvas: false, bgPattern: 'bg-grid', bgColor: '#FFFFFF', showSeq: true, // sequence numbers on edges showNotes: true, // memo cards visibility toggle showAnnotations: true, // duration/cost chips visibility toggle // Default currency for the trip — the symbol auto-prefixed onto bare // numbers entered in the cost field. JP version defaults to '¥'; the // user can switch via the toolbar picker for foreign trips. Existing // entries with explicit symbols ($, €, etc.) are preserved verbatim // and surface as separate per-currency totals in the summary bar. defaultCurrency: '¥', nodes: [], edges: [], groups: [], notes: [], // memo / sticky notes (can anchor to a node/edge) }; const hist = useHistory(initial); const { state, set } = hist; const [selection, setSelection] = React.useState(null); const [view, setView] = React.useState({ zoom: 1, tx: 200, ty: 100 }); const [editingEdgeId, setEditingEdgeId] = React.useState(null); const [toast, setToast] = React.useState(null); const [shotMode, setShotMode] = React.useState(false); const [composingGroupId, setComposingGroupId] = React.useState(null); // (seqSwapMode / seqSwapFirst removed — sequence is auto-derived by time // now; users no longer hand-shuffle edge numbers.) // 工程表 (Schedule view) overlay const [showSchedule, setShowSchedule] = React.useState(false); // Note anchor pick mode: when set, the next click on a node/edge becomes // the anchor target for that note. Same UX pattern as composingGroupId. // Shape: { noteId: string, type: 'node' | 'edge' } const [notePickMode, setNotePickMode] = React.useState(null); // Edge re-target pick mode: clicking a node sets that edge's from or to // endpoint to the chosen node. Useful for inserting/rerouting. // Shape: { edgeId: string, endpoint: 'from' | 'to' } const [edgePickMode, setEdgePickMode] = React.useState(null); // ── Custom confirm dialog (replaces native confirm()) ── // Shape: { title, message, confirmLabel, cancelLabel, tone, onConfirm, onCancel } | null // Tone: 'primary' (green / default), 'danger' (red — for destructive ops). const [confirmDialog, setConfirmDialog] = React.useState(null); // Promise-based wrapper so callers can `await askConfirm(...)` instead of // restructuring around callbacks. The dialog handlers resolve the promise. const askConfirm = (opts) => new Promise((resolve) => { setConfirmDialog({ title: opts.title || '確認', message: opts.message || '', confirmLabel: opts.confirmLabel || 'はい', cancelLabel: opts.cancelLabel || 'キャンセル', tone: opts.tone || 'primary', onConfirm: () => { setConfirmDialog(null); resolve(true); }, onCancel: () => { setConfirmDialog(null); resolve(false); }, }); }); const shotPrevViewRef = React.useRef(null); const canvasRef = React.useRef(null); const flash = (msg)=>{ setToast(msg); setTimeout(()=>setToast(null), 1800); }; // ── id helpers ───────────────────────────────────────────── const nextId = (prefix) => prefix + '_' + Math.random().toString(36).slice(2,8); const getViewCenter = () => { const stage = canvasRef.current; const rect = stage?.getBoundingClientRect(); return { x: rect ? (rect.width/2 - view.tx)/view.zoom : 400, y: rect ? (rect.height/2 - view.ty)/view.zoom : 300, }; }; // ── Nodes ────────────────────────────────────────────────── const addNode = (partial={}) => { const c = getViewCenter(); const type = partial.type ? NODE_TYPES.find(t => t.id === partial.type) : null; const id = nextId('n'); set(s => ({ ...s, nodes:[...s.nodes, { id, x: c.x + (Math.random()-0.5)*40, y: c.y + (Math.random()-0.5)*40, name: type ? type.label : '新しい地点', color: type?.color || '#FFFFFF', icon: type?.icon || 'map-pin', emoji: null, image: null, shape: type?.shape || 'square', // Datetime fields. Both optional — the user fills only what they know. // arrival — 到着日時 (ISO local "YYYY-MM-DDTHH:MM") // departure — 出発日時 // cost — 諸費用 (入場料・宿泊費・etc.) // Edge transit time is auto-derived from prev.departure → next.arrival. arrival: '', departure: '', cost: '', ...partial, }] })); setSelection({type:'node', id}); }; const updateNode = (id, patch, opts) => { set(s => ({ ...s, nodes: s.nodes.map(n => n.id===id ? { ...n, ...patch } : n), }), opts); }; const deleteNode = (id) => { set(s => { const groups = (s.groups || []) .map(g => ({ ...g, nodeIds: (g.nodeIds || []).filter(nid => nid !== id) })) .filter(g => (g.nodeIds || []).length > 0); // Detach any notes anchored to this node. const notes = (s.notes || []).map(n => (n.anchor && n.anchor.type === 'node' && n.anchor.id === id) ? { ...n, anchor: null } : n ); return { ...s, nodes: s.nodes.filter(n=>n.id!==id), edges: s.edges.filter(e=>e.from!==id && e.to!==id), groups, notes, }; }); setSelection(null); }; // Duplicate a node — creates a copy at a slight offset (so it's visible) // with a fresh id. Edges, groups, and notes are NOT copied (those reference // ids; the duplicate starts as a free node). const duplicateNode = (id) => { const orig = state.nodes.find(n => n.id === id); if (!orig) return; const newId = nextId('n'); const dup = { ...orig, id: newId, x: orig.x + 40, y: orig.y + 40 }; set(s => ({ ...s, nodes: [...s.nodes, dup] })); setSelection({ type: 'node', id: newId }); flash('地点を複製しました'); }; // ── Edges ────────────────────────────────────────────────── // edge.seq is the planned-order number, INDEPENDENT of array position. // Array position controls z-order only (later edges paint over earlier). const nextSeq = (edges) => { const max = (edges || []).reduce((m, e) => Math.max(m, e.seq || 0), 0); return max + 1; }; const addEdge = (fromId, toId, partial={}) => { const id = nextId('e'); const rel = RELATIONS.find(r => r.id === 'jr') || RELATIONS[0]; set(s => ({ ...s, edges:[...s.edges, { id, from:fromId, to:toId, relation: rel.id, style: rel.defaultStyle || 'oneway', color: rel.color, label: null, stamp: rel.stamp, seq: nextSeq(s.edges), // duration is no longer stored; it's derived from from.departure → to.arrival. cost: '', ...partial, }] })); setSelection({type:'edge', id}); }; const updateEdge = (id, patch, opts) => { set(s => ({ ...s, edges: s.edges.map(e => e.id===id ? { ...e, ...patch } : e), }), opts); }; const deleteEdge = (id) => { set(s => { // Detach any notes anchored to this edge. const notes = (s.notes || []).map(n => (n.anchor && n.anchor.type === 'edge' && n.anchor.id === id) ? { ...n, anchor: null } : n ); return { ...s, edges: s.edges.filter(e=>e.id!==id), notes, }; }); setSelection(null); }; // (moveEdgeOrder / swapEdgeSeq / setEdgeSeq removed — sequence is auto- // derived from each node's arrival/departure times, so manual reorder // helpers no longer have a UI surface to attach to.) // Z-order swap (Shift+click on edges). Swaps array positions ONLY — used // when two edges visually overlap and the user wants to bring one forward. const swapEdgeZ = (id1, id2) => { set(s => { const i1 = s.edges.findIndex(e => e.id === id1); const i2 = s.edges.findIndex(e => e.id === id2); if (i1 < 0 || i2 < 0 || i1 === i2) return s; const edges = [...s.edges]; [edges[i1], edges[i2]] = [edges[i2], edges[i1]]; return {...s, edges}; }); }; // ── Groups (Day 1 / Day 2 / etc.) ────────────────────────── const GROUP_COLORS = ['#10A37F','#3B82F6','#F59E0B','#7C3AED','#EF4444','#0891B2','#84CC16']; const createGroupFromNode = (nodeId) => { const id = nextId('g'); const usedColors = (state.groups || []).map(g=>g.color); const color = GROUP_COLORS.find(c => !usedColors.includes(c)) || GROUP_COLORS[0]; set(s => ({ ...s, groups: [...(s.groups || []), { id, name:`Day ${(s.groups||[]).length + 1}`, color, showName:true, nodeIds:[nodeId], }], })); return id; }; const updateGroup = (id, patch, opts) => { set(s => ({ ...s, groups: (s.groups || []).map(g => g.id === id ? { ...g, ...patch } : g), }), opts); }; const deleteGroup = (id) => { set(s => ({ ...s, groups: (s.groups || []).filter(g => g.id !== id), })); setSelection(null); if (composingGroupId === id) setComposingGroupId(null); }; // ── Auto-connect by time (reactive) ─────────────────────────── // Whenever a node's arrival/departure changes, re-derive the chain of // edges from the time-sorted node list and apply the diff against the // current edge set: // - Pairs that already exist as edges → preserved (keeps user-customised // color, label, cost, etc.). // - Pairs missing in current → new edges are created and flagged in // whooshEdgeIds so Edge.jsx plays the "drawing on" animation. // - Pairs in current but NOT in the new chain → those represent the // "rewiring" case (e.g. inserting a new node BETWEEN two connected // ones). We confirm with the user before removing them, naming the // affected legs ("「東京駅」→「箱根」 の区間が変更されます"). // Edges between untimed nodes are passed through untouched. const [whooshEdgeIds, setWhooshEdgeIds] = React.useState(new Set()); // A signature string that changes ONLY when a node's time fields move. // Keying the effect on this avoids spurious re-runs on position drags. const timeSig = React.useMemo( () => (state.nodes || []).map(n => `${n.id}|${n.arrival||''}|${n.departure||''}`).sort().join(';'), [state.nodes] ); // Suppresses re-prompting the same confirmation after the user cancels. const ignoredTimeSigRef = React.useRef(''); // Skips the very first effect run — we don't want a confirmation dialog // popping up just because the user loaded a save from localStorage. const initialMountRef = React.useRef(true); // Single source of truth for the auto-sync logic. Called both: // • debounced from the useEffect below (reactive, on time changes) // • directly from the toolbar button (force = bypass cancel memory) // Async because the rewiring confirmation now uses a Promise-based // custom dialog rather than the browser's native confirm(). const syncEdgesByTime = async (opts = {}) => { const force = opts.force === true; const timed = state.nodes .map(n => ({ n, t: n.departure || n.arrival || null })) .filter(x => x.t) .sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0)); if (timed.length < 2){ if (force) flash('時刻が入ったノードを2つ以上配置してから実行してください'); return; } const timedIds = new Set(timed.map(x => x.n.id)); const currentTimedEdges = (state.edges || []).filter(e => timedIds.has(e.from) && timedIds.has(e.to) ); const currentPairSet = new Set(currentTimedEdges.map(e => `${e.from}→${e.to}`)); // Desired chain pairs in time order. const desiredPairs = []; for (let i = 0; i < timed.length - 1; i++){ desiredPairs.push([timed[i].n, timed[i + 1].n]); } const desiredPairSet = new Set(desiredPairs.map(([a, b]) => `${a.id}→${b.id}`)); // toAdd / toRemove drive whether we even need to act, and whether // it counts as "rewiring" (= asking for confirmation). const toAdd = desiredPairs.filter(([a, b]) => !currentPairSet.has(`${a.id}→${b.id}`)); const toRemove = currentTimedEdges.filter(e => !desiredPairSet.has(`${e.from}→${e.to}`)); if (toAdd.length === 0 && toRemove.length === 0){ if (force) flash('既に時刻順に並んでいます'); return; } // Confirm BEFORE applying when an existing edge would be removed // (the "in-between insertion" scenario the user called out). if (toRemove.length > 0){ const nameOf = (id) => state.nodes.find(n => n.id === id)?.name || '?'; const legs = toRemove.map(e => `「${nameOf(e.from)}」→「${nameOf(e.to)}」`).join('、'); const ok = await askConfirm({ title: '経路を組み直しますか?', message: `時刻順が変わったため、${legs} の区間が変更されます。`, confirmLabel: 'はい、変更する', cancelLabel: 'やめる', tone: 'primary', }); if (!ok){ // Remember this exact time signature so we don't re-prompt for // the same change. A later edit creates a new sig and re-engages. ignoredTimeSigRef.current = timeSig; return; } } // Build new edges. Preserve existing edges where the (from→to) pair // matches the desired chain (keeps user-customised fields), only // creating fresh edges for the truly new pairs (those get whooshed). const guessRel = (a, b) => { const transit = (n) => n.icon === 'train-front' || n.icon === 'tram-front' || n.icon === 'plane' || n.icon === 'ship' || n.icon === 'sailboat' || n.icon === 'bus' || n.icon === 'taxi' || n.icon === 'car'; if (transit(a) && transit(b)) return RELATIONS.find(r => r.id === 'jr') || RELATIONS[0]; return RELATIONS.find(r => r.id === 'walk') || RELATIONS[0]; }; const existingByPair = new Map(); (state.edges || []).forEach(e => existingByPair.set(`${e.from}→${e.to}`, e)); const newChain = []; const freshIds = new Set(); desiredPairs.forEach(([a, b], i) => { const pair = `${a.id}→${b.id}`; const existing = existingByPair.get(pair); if (existing){ newChain.push({ ...existing, seq: i + 1 }); } else { const id = nextId('e'); const rel = guessRel(a, b); newChain.push({ id, from: a.id, to: b.id, relation: rel.id, style: rel.defaultStyle || 'oneway', color: rel.color, label: null, stamp: rel.stamp, seq: i + 1, cost: '', }); freshIds.add(id); } }); // Preserve edges between untimed nodes (or where one endpoint is // missing a time) — we only touch edges between two timed nodes. const untimedEdges = (state.edges || []).filter(e => !timedIds.has(e.from) || !timedIds.has(e.to) ); set(s => ({ ...s, edges: [...untimedEdges, ...newChain] })); // Whoosh animation only on the freshly-added pairs. if (freshIds.size > 0){ setWhooshEdgeIds(freshIds); setTimeout(() => setWhooshEdgeIds(new Set()), 1600); } if (force) flash(`矢印を時刻順に再配線しました`); }; // Reactive trigger: 500ms debounce on time changes. Skips the very first // mount (load from localStorage shouldn't pop a dialog). React.useEffect(() => { if (initialMountRef.current){ initialMountRef.current = false; return; } if (timeSig === ignoredTimeSigRef.current) return; const h = setTimeout(() => { syncEdgesByTime(); }, 500); return () => clearTimeout(h); // eslint-disable-next-line react-hooks/exhaustive-deps }, [timeSig]); // Toolbar button — same logic but bypasses the "user cancelled" memory // and always shows a flash so the action feels responsive. const autoConnectByTime = () => { ignoredTimeSigRef.current = ''; syncEdgesByTime({ force: true }); }; // Auto-create groups from each unique date present in the nodes' arrival / // departure datetimes. A node belongs to EVERY date it touches (so a hotel // with arrival 4/28 and departure 4/29 ends up in BOTH "1日目" and "2日目" // groups — the natural "stayover bridges two days" semantics). const autoGroupByDate = async () => { const dt = window.tourDateTime; if (!dt){ flash('日付ヘルパーが読み込まれていません'); return; } const dateToNodes = new Map(); state.nodes.forEach(n => { const dates = new Set(); [n.arrival, n.departure].forEach(s => { const d = dt.toDate(s); if (d) dates.add(`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`); }); dates.forEach(key => { if (!dateToNodes.has(key)) dateToNodes.set(key, new Set()); dateToNodes.get(key).add(n.id); }); }); if (dateToNodes.size === 0){ flash('日付が入力されたノードがありません'); return; } if ((state.groups || []).length > 0){ const ok = await askConfirm({ title: 'グループを置き換えますか?', message: `現在の${state.groups.length}個のグループを削除して、日付別グループ(${dateToNodes.size}日分)を生成します。`, confirmLabel: 'はい、置き換える', cancelLabel: 'やめる', tone: 'primary', }); if (!ok) return; } const sortedDates = Array.from(dateToNodes.keys()).sort(); const newGroups = sortedDates.map((key, i) => { const [y, m, d] = key.split('-').map(Number); return { id: `g_auto_${i+1}_${Math.random().toString(36).slice(2,6)}`, name: `${i+1}日目 (${m}/${d})`, color: GROUP_COLORS[i % GROUP_COLORS.length], showName: true, nodeIds: Array.from(dateToNodes.get(key)), labelOffset: { dx: 0, dy: -30 }, }; }); set(s => ({ ...s, groups: newGroups })); flash(`${newGroups.length}日分のグループを自動生成しました`); }; const toggleGroupMember = (groupId, nodeId) => { set(s => { const groups = (s.groups || []).map(g => { if (g.id !== groupId) return g; const has = (g.nodeIds || []).includes(nodeId); const nodeIds = has ? g.nodeIds.filter(x => x !== nodeId) : [...(g.nodeIds || []), nodeId]; return { ...g, nodeIds }; }) .filter(g => g.id === groupId || (g.nodeIds || []).length > 0); return { ...s, groups }; }); }; // ── Notes ────────────────────────────────────────────────── // Notes can optionally anchor to a node or edge. The anchor causes a // dotted line to be drawn from the note to the target's center. const addNote = (partial={}) => { const c = getViewCenter(); const id = nextId('note'); set(s => ({ ...s, notes: [...(s.notes || []), { id, x: c.x, y: c.y, text: '', color: '#FFFBEB', anchor: null, ...partial, }], })); setSelection({type:'note', id}); }; const updateNote = (id, patch, opts) => { set(s => ({ ...s, notes: (s.notes || []).map(n => n.id===id ? { ...n, ...patch } : n), }), opts); }; const deleteNote = (id) => { set(s => ({ ...s, notes: (s.notes || []).filter(n => n.id !== id), })); setSelection(null); }; // Duplicate a note — copy text/color/anchor at slight offset. const duplicateNote = (id) => { const orig = (state.notes || []).find(n => n.id === id); if (!orig) return; const newId = nextId('note'); const dup = { ...orig, id: newId, x: orig.x + 24, y: orig.y + 24 }; set(s => ({ ...s, notes: [...(s.notes || []), dup] })); setSelection({ type: 'note', id: newId }); flash('メモを複製しました'); }; // Enter pick mode for a note's anchor. Canvas intercepts the next node / // edge click depending on `type` and routes it to setNoteAnchorTo. const startNoteAnchorPick = (noteId, type) => { setNotePickMode({ noteId, type }); flash(type === 'node' ? '紐づけ先となる地点をクリックしてください(Esc で取消)' : '紐づけ先となる矢印をクリックしてください(Esc で取消)'); }; const cancelNoteAnchorPick = () => setNotePickMode(null); const setNoteAnchorTo = (targetId) => { if (!notePickMode) return; const { noteId, type } = notePickMode; updateNote(noteId, { anchor: { type, id: targetId } }); setNotePickMode(null); flash('メモを紐づけました'); }; // Edge re-target pick mode handlers — change an existing edge's from / to // by clicking a different node. Used for fast re-routing when plans change. const startEdgeRetargetPick = (edgeId, endpoint) => { setEdgePickMode({ edgeId, endpoint }); flash(endpoint === 'from' ? '新しい出発地点をクリックしてください(Esc で取消)' : '新しい行き先をクリックしてください(Esc で取消)'); }; const cancelEdgeRetargetPick = () => setEdgePickMode(null); const setEdgeEndpointTo = (nodeId) => { if (!edgePickMode) return; const { edgeId, endpoint } = edgePickMode; const edge = state.edges.find(e => e.id === edgeId); if (!edge) { setEdgePickMode(null); return; } // Self-loop guard: can't pick the same node as the other endpoint const otherId = endpoint === 'from' ? edge.to : edge.from; if (nodeId === otherId){ flash('反対側と同じ地点は選べません'); return; } updateEdge(edgeId, { [endpoint]: nodeId }); setEdgePickMode(null); flash(endpoint === 'from' ? '出発地点を変更しました' : '行き先を変更しました'); }; const clearAll = async () => { const ok = await askConfirm({ title: 'すべてクリアしますか?', message: 'すべてのノード・矢印・メモを削除します。この操作は元に戻せません(Undo は効きます)。', confirmLabel: 'はい、クリアする', cancelLabel: 'やめる', tone: 'danger', }); if (!ok) return; set(s => ({...s, nodes:[], edges:[], groups:[], notes:[], title:'', subtitle:'', showTitleOnCanvas:false, bgPattern:'bg-grid', bgColor:'#FFFFFF'})); setSelection(null); setComposingGroupId(null); try { localStorage.removeItem('tour_saved_data'); } catch(e) {} }; // ── Demo data ────────────────────────────────────────────── // Two flavors: // 'travel' — 旅行 (箱根 1泊2日, default) // 'business' — 出張 (大阪 1泊2日 商談) const loadDemo = (which) => { const demo = which === 'business' ? buildBusinessDemoData() : buildTravelDemoData(); set(s => ({ ...s, nodes: demo.nodes, edges: demo.edges, groups: demo.groups || [], notes: demo.notes || [], title: demo.title, subtitle: demo.subtitle, showTitleOnCanvas: true, bgPattern: 'bg-grid', bgColor: '#FFFFFF', })); setView({ zoom: 0.85, tx: 80, ty: 60 }); setSelection(null); flash(which === 'business' ? 'サンプル出張を読み込みました' : 'サンプル旅程を読み込みました'); }; // ── Settings ─────────────────────────────────────────────── const setTitle = (t)=> set(s=>({...s,title:t})); const setSubtitle = (t)=> set(s=>({...s,subtitle:t})); const setDefaultCurrency = (c)=> set(s=>({...s,defaultCurrency:c})); const setShowTitleOnCanvas = (v)=> set(s=>({...s,showTitleOnCanvas:v})); const setBgPattern = (v)=> set(s=>({...s,bgPattern:v})); const setBgColor = (v)=> set(s=>({...s,bgColor:v})); const setShowSeq = (v)=> set(s=>({...s,showSeq:v})); const setShowNotes = (v)=> set(s=>({...s,showNotes:v})); const setShowAnnotations = (v)=> set(s=>({...s,showAnnotations:v})); // ── Project file save / load ─────────────────────────────── const saveProject = () => { const data = JSON.stringify(state, null, 2); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (state.title || '出張・旅程') + '.tour.json'; a.click(); URL.revokeObjectURL(url); flash('作業ファイルを保存しました'); }; const loadProjectFileRef = React.useRef(null); const loadProject = () => { loadProjectFileRef.current?.click(); }; const onLoadProjectFile = (e) => { const f = e.target.files?.[0]; if (!f) return; const reader = new FileReader(); reader.onload = async () => { try { const data = JSON.parse(reader.result); const ok = await askConfirm({ title: '作業を上書きしますか?', message: '現在の作業内容は読み込んだファイルで上書きされます。', confirmLabel: 'はい、読み込む', cancelLabel: 'やめる', tone: 'primary', }); if (!ok) return; // Migrate older saves: ensure each edge has a .seq value. const edges = (data.edges ?? []).map((e, i) => ({ ...e, seq: (typeof e.seq === 'number' ? e.seq : i + 1), })); set(s => ({ ...s, title: data.title ?? '', subtitle: data.subtitle ?? '', showTitleOnCanvas: data.showTitleOnCanvas ?? false, bgPattern: data.bgPattern ?? 'bg-grid', bgColor: data.bgColor ?? '#FFFFFF', showSeq: data.showSeq ?? true, showNotes: data.showNotes ?? true, showAnnotations: data.showAnnotations ?? true, // defaultCurrency must be restored from the file too — without this, // saving a USD/EUR trip and reloading it would silently revert to // the local default ('¥' on JP / '$' on EN), and existing cost rows // would suddenly be totalled under the wrong symbol. defaultCurrency: data.defaultCurrency ?? s.defaultCurrency ?? '¥', nodes: data.nodes ?? [], edges, groups: data.groups ?? [], notes: data.notes ?? [], })); setSelection(null); setComposingGroupId(null); setView({ zoom: 1, tx: 200, ty: 100 }); flash('作業ファイルを読み込みました'); } catch(err) { flash('ファイルの読み込みに失敗しました'); console.error(err); } }; reader.readAsText(f); e.target.value = ''; }; // ── Screenshot mode ──────────────────────────────────────── const enterShotMode = () => { if (state.nodes.length === 0) { flash('ノードを追加してください'); return; } setSelection(null); const pad = 80; let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity; state.nodes.forEach(n=>{ minX = Math.min(minX, n.x-60); minY = Math.min(minY, n.y-60); maxX = Math.max(maxX, n.x+60); maxY = Math.max(maxY, n.y+60); }); if (state.showNotes !== false){ (state.notes || []).forEach(n => { minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); maxX = Math.max(maxX, n.x + 200); maxY = Math.max(maxY, n.y + 60); }); } if (state.showTitleOnCanvas) minY = Math.min(minY, 0); minX-=pad; minY-=pad; maxX+=pad; maxY+=pad; const bw = maxX-minX, bh = maxY-minY; const vw = window.innerWidth, vh = window.innerHeight; const zoom = Math.min(1.5, Math.max(0.3, Math.min(vw/bw, vh/bh))); const tx = (vw - bw*zoom)/2 - minX*zoom; const ty = (vh - bh*zoom)/2 - minY*zoom; shotPrevViewRef.current = view; setView({ zoom, tx, ty }); setShotMode(true); document.body.classList.add('is-shot-mode'); }; const exitShotMode = () => { setShotMode(false); document.body.classList.remove('is-shot-mode'); if (shotPrevViewRef.current) { setView(shotPrevViewRef.current); shotPrevViewRef.current = null; } }; // ── Keyboard ─────────────────────────────────────────────── React.useEffect(()=>{ const onKey = (e) => { if (shotMode && e.key==='Escape') { exitShotMode(); return; } if (e.key==='Escape' && composingGroupId) { setComposingGroupId(null); return; } if (e.key==='Escape' && notePickMode) { cancelNoteAnchorPick(); return; } if (e.key==='Escape' && edgePickMode) { cancelEdgeRetargetPick(); return; } if (e.target.matches('input,textarea')) return; if ((e.metaKey||e.ctrlKey) && e.key==='z' && !e.shiftKey){ e.preventDefault(); hist.undo(); } else if ((e.metaKey||e.ctrlKey) && (e.key==='y' || (e.shiftKey && e.key==='z'))){ e.preventDefault(); hist.redo(); } else if (e.key==='Delete' || e.key==='Backspace'){ if (selection?.type==='node') deleteNode(selection.id); if (selection?.type==='edge') deleteEdge(selection.id); if (selection?.type==='group') deleteGroup(selection.id); if (selection?.type==='note') deleteNote(selection.id); } }; window.addEventListener('keydown', onKey); return ()=> window.removeEventListener('keydown', onKey); }, [selection, hist, shotMode, composingGroupId, notePickMode, edgePickMode]); // (onSeqSwapClick removed — sequence numbers are now auto-derived from // node times, no manual swap mode.) return (