dsh-synapse 0.5.3 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app.js +98 -3
- package/package.json +1 -1
package/app.js
CHANGED
|
@@ -1268,7 +1268,7 @@ function render() {
|
|
|
1268
1268
|
const canvasControls = state.mode === 'canvas' && (threads.length > 0 || state.draft?.kind === 'new') ? `<div class="canvas-controls"><button data-action="layout" title="整理节点" aria-label="整理节点"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="9" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="2.5" y="9" width="4.5" height="4.5" rx="1"/><rect x="9" y="9" width="4.5" height="4.5" rx="1"/></svg>整理</button><button data-action="focus-active" title="定位到当前会话" aria-label="定位到当前会话"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><circle cx="8" cy="8" r="3.2"/><path d="M8 1.5v2.6M8 11.9v2.6M1.5 8h2.6M11.9 8h2.6"/></svg>定位</button><button data-action="zoom-out" aria-label="缩小" title="缩小"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M3.5 8h9"/></svg></button><span>${Math.round(state.zoom * 100)}%</span><button data-action="zoom-in" aria-label="放大" title="放大"><svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M8 3.5v9M3.5 8h9"/></svg></button></div>` : ''
|
|
1269
1269
|
const detailAvailable = currentThread() !== null
|
|
1270
1270
|
const canvasTabs = `<nav class="canvas-tabs" aria-label="会话地图视图"><button class="${state.mode === 'canvas' ? 'active' : ''}" data-action="show-canvas">地图</button><button class="${state.mode === 'thread' ? 'active' : ''}" data-action="show-thread" data-thread="${state.activeId ?? ''}" ${detailAvailable ? '' : 'disabled'}>详情</button></nav>`
|
|
1271
|
-
app
|
|
1271
|
+
morphInto(app, `<main class="synapse-shell ${state.sidebarCollapsed ? 'sidebar-collapsed' : ''}"><aside class="sidebar"><div class="sidebar-brand-row"><div class="brand" aria-label="Synapse"><svg class="brand-mark" aria-hidden="true" viewBox="0 0 32 32" fill="none"><path d="M9 10.5 16 7l7 3.5M9 10.5v8L16 22m0-15v15m7-11.5v8L16 22"/><circle cx="9" cy="10" r="2.5"/><circle cx="23" cy="10" r="2.5"/><circle cx="16" cy="23" r="2.5"/></svg><strong>Synapse</strong></div><button class="sidebar-toggle" type="button" data-action="toggle-sidebar" aria-label="${state.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}" title="${state.sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}"><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="1.75" y="1.75" width="12.5" height="12.5" rx="2.25"/><path d="M6 2v12"/></svg></button></div><button class="new-workspace" type="button" data-action="create-session" ${state.draft !== null ? 'disabled' : ''}><svg class="new-session-icon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M8 4.75v6.5M4.75 8h6.5"/></svg><span>新会话</span></button><label class="workspace-label"><span>工作区</span><span class="workspace-select"><svg aria-hidden="true" viewBox="0 0 16 16"><path d="M2.5 4.75h3l1.2 1.5h6.8v5.5a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z"/></svg><select data-action="select-workspace" aria-label="选择工作区" ${state.draft !== null ? 'disabled' : ''}>${choices.map(item => `<option value="${item.id}" title="${escapeHtml(item.path ?? item.title)}" ${item.id === selectedWorkspaceId ? 'selected' : ''}>${escapeHtml(item.title)}</option>`).join('')}</select></span></label><div class="sidebar-heading"><span>会话</span></div><nav class="thread-tree">${threads.map(thread => `<button class="tree-row ${thread.id === state.activeId ? 'active' : ''}" data-action="select-thread" data-thread="${thread.id}" style="--thread-color:#374151"><span class="tree-dot"></span><span>${escapeHtml(threadListTitle(thread))}</span>${thread.parentId === null ? '' : '<i>分支</i>'}</button>`).join('') || '<p class="tree-empty">暂未同步会话</p>'}</nav></aside><header class="topbar">${canvasControls}</header><section class="main-stage">${state.error ? `<div class="status-message" role="alert"><span>${escapeHtml(state.error)}</span><button data-action="dismiss-error" aria-label="关闭" title="关闭">×</button></div>` : ''}${canvasTabs}${view}${selectionFollowupButton()}</section></main>`)
|
|
1272
1272
|
installDragging()
|
|
1273
1273
|
cacheCardConnectors()
|
|
1274
1274
|
// The initial camera from renderCanvas is inset (viewport not laid out yet);
|
|
@@ -1416,7 +1416,100 @@ function applyCanvasTransform() {
|
|
|
1416
1416
|
if (content instanceof HTMLElement) content.style.transform = `translate(${state.canvasCamera.x}px, ${state.canvasCamera.y}px) scale(${state.zoom})`
|
|
1417
1417
|
}
|
|
1418
1418
|
|
|
1419
|
+
// --- DOM morphing ---------------------------------------------------------
|
|
1420
|
+
// The canvas re-renders on every projection tick and on every streamed chunk.
|
|
1421
|
+
// Assigning `app.innerHTML` destroys the exact node the user is pressing, and a
|
|
1422
|
+
// click is only dispatched when mousedown and mouseup share a target, so a
|
|
1423
|
+
// rebuild in between silently swallows the click (W3C uievents #141). Morph the
|
|
1424
|
+
// new markup into the live tree instead: matching nodes are patched in place and
|
|
1425
|
+
// whatever sits under the pointer stays alive.
|
|
1426
|
+
|
|
1427
|
+
const MORPH_KEY_ATTRIBUTES = ['data-morph-key', 'data-card-id', 'data-thread', 'id']
|
|
1428
|
+
|
|
1429
|
+
function morphKeyOf(node) {
|
|
1430
|
+
if (!(node instanceof Element)) return null
|
|
1431
|
+
for (const attribute of MORPH_KEY_ATTRIBUTES) {
|
|
1432
|
+
const value = node.getAttribute(attribute)
|
|
1433
|
+
if (value !== null && value !== '') return `${node.tagName}|${attribute}|${value}`
|
|
1434
|
+
}
|
|
1435
|
+
return null
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** An element that owns the caret keeps its children untouched. */
|
|
1439
|
+
function morphHoldsCaret(node) {
|
|
1440
|
+
if (!(node instanceof Element)) return false
|
|
1441
|
+
if (node === document.activeElement) return true
|
|
1442
|
+
const selection = typeof window.getSelection === 'function' ? window.getSelection() : null
|
|
1443
|
+
if (selection === null || selection.rangeCount === 0) return false
|
|
1444
|
+
const anchor = selection.anchorNode
|
|
1445
|
+
return anchor !== null && node.contains(anchor)
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function morphNode(from, to) {
|
|
1449
|
+
if (from.nodeType !== to.nodeType) return replaceMorphed(from, to)
|
|
1450
|
+
if (from.nodeType === Node.TEXT_NODE || from.nodeType === Node.COMMENT_NODE) {
|
|
1451
|
+
if (from.nodeValue !== to.nodeValue) from.nodeValue = to.nodeValue
|
|
1452
|
+
return from
|
|
1453
|
+
}
|
|
1454
|
+
if (!(from instanceof Element) || !(to instanceof Element) || from.tagName !== to.tagName) {
|
|
1455
|
+
return replaceMorphed(from, to)
|
|
1456
|
+
}
|
|
1457
|
+
if (morphHoldsCaret(from)) return from
|
|
1458
|
+
for (const attribute of [...from.attributes]) {
|
|
1459
|
+
if (!to.hasAttribute(attribute.name)) from.removeAttribute(attribute.name)
|
|
1460
|
+
}
|
|
1461
|
+
for (const attribute of [...to.attributes]) {
|
|
1462
|
+
if (from.getAttribute(attribute.name) !== attribute.value) from.setAttribute(attribute.name, attribute.value)
|
|
1463
|
+
}
|
|
1464
|
+
morphChildren(from, to)
|
|
1465
|
+
return from
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/** Swap one node for a copy of another and hand back the node now in the tree. */
|
|
1469
|
+
function replaceMorphed(from, to) {
|
|
1470
|
+
const next = to.cloneNode(true)
|
|
1471
|
+
from.replaceWith(next)
|
|
1472
|
+
return next
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function morphChildren(from, to) {
|
|
1476
|
+
const keyed = new Map()
|
|
1477
|
+
for (const child of [...from.childNodes]) {
|
|
1478
|
+
const key = morphKeyOf(child)
|
|
1479
|
+
if (key !== null && !keyed.has(key)) keyed.set(key, child)
|
|
1480
|
+
}
|
|
1481
|
+
const matched = new Set()
|
|
1482
|
+
let cursor = from.firstChild
|
|
1483
|
+
for (const next of [...to.childNodes]) {
|
|
1484
|
+
const key = morphKeyOf(next)
|
|
1485
|
+
let match = key === null ? null : keyed.get(key) ?? null
|
|
1486
|
+
if (match === null && cursor !== null && morphKeyOf(cursor) === null) match = cursor
|
|
1487
|
+
if (match === null) {
|
|
1488
|
+
const inserted = next.cloneNode(true)
|
|
1489
|
+
from.insertBefore(inserted, cursor)
|
|
1490
|
+
matched.add(inserted)
|
|
1491
|
+
continue
|
|
1492
|
+
}
|
|
1493
|
+
if (match !== cursor) from.insertBefore(match, cursor)
|
|
1494
|
+
matched.add(morphNode(match, next))
|
|
1495
|
+
cursor = match.nextSibling
|
|
1496
|
+
}
|
|
1497
|
+
for (const child of [...from.childNodes]) {
|
|
1498
|
+
if (!matched.has(child)) from.removeChild(child)
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/** Patch `container`'s children to match `html` without discarding live nodes. */
|
|
1503
|
+
function morphInto(container, html) {
|
|
1504
|
+
const template = document.createElement('template')
|
|
1505
|
+
template.innerHTML = html
|
|
1506
|
+
morphChildren(container, template.content)
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1419
1509
|
function bindDragHandle(handle) {
|
|
1510
|
+
// Cards now survive a render, so bind each drag handle once.
|
|
1511
|
+
if (handle.dataset.dragBound === '1') return
|
|
1512
|
+
handle.dataset.dragBound = '1'
|
|
1420
1513
|
handle.addEventListener('pointerdown', event => {
|
|
1421
1514
|
const cardId = event.currentTarget.dataset.dragCard
|
|
1422
1515
|
const card = event.currentTarget.closest('.thread-card')
|
|
@@ -1925,9 +2018,11 @@ function applyLiveReplyToCard(sessionId) {
|
|
|
1925
2018
|
const answer = card.querySelector('.thread-answer')
|
|
1926
2019
|
if (!(answer instanceof HTMLElement)) return
|
|
1927
2020
|
const text = live.text
|
|
1928
|
-
answer
|
|
2021
|
+
// Morph instead of replacing the subtree: the answer keeps its nodes, so a
|
|
2022
|
+
// pointer that went down inside it still resolves to a click on release.
|
|
2023
|
+
morphInto(answer, text.trim() === ''
|
|
1929
2024
|
? '<p class="thread-answer-pending">正在回复</p>'
|
|
1930
|
-
: `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`
|
|
2025
|
+
: `${renderMarkdown(text)}<p class="thread-answer-pending">正在回复</p>`)
|
|
1931
2026
|
}
|
|
1932
2027
|
function scheduleLiveRender() {
|
|
1933
2028
|
if (liveRenderTimer !== 0 || !canReplaceView()) return
|