pmx-canvas 0.7.0 → 0.7.1

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.
@@ -1,9 +1,10 @@
1
- import { useEffect, useState } from 'preact/hooks';
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { cameraSchema, interpolateCamera, resolveStop, type Camera, type Tour } from '../../shared/tour.js';
3
3
  import { canvasArea } from './canvas-area';
4
4
  import { cancelViewportAnimation, hasInitialServerLayout, nodes, viewport } from '../state/canvas-store';
5
5
  import { presenting, recordingCamera } from '../state/presentation';
6
6
  import { requestJson } from '../state/intent-bridge';
7
+ import { usePanZoom } from './use-pan-zoom';
7
8
 
8
9
  declare global {
9
10
  interface Window {
@@ -22,6 +23,17 @@ export function Presentation() {
22
23
  const [error, setError] = useState('');
23
24
  const active = presenting.value;
24
25
  const ready = hasInitialServerLayout.value;
26
+ const frame = useRef(0);
27
+ const moveCamera = (camera: Camera) => {
28
+ cancelAnimationFrame(frame.current);
29
+ viewport.value = camera;
30
+ };
31
+ // A separate gesture surface keeps cards read-only and camera changes local.
32
+ const navigationRef = usePanZoom({
33
+ viewport,
34
+ onViewportChange: moveCamera,
35
+ onViewportCommit: moveCamera,
36
+ });
25
37
  useEffect(() => {
26
38
  const params = new URLSearchParams(location.search);
27
39
  presenting.value = params.get('present') === '1';
@@ -76,20 +88,20 @@ export function Presentation() {
76
88
  const from = { ...viewport.value };
77
89
  const start = performance.now();
78
90
  const duration = (stop.duration ?? 1) * 1000;
79
- let frame = 0;
80
91
  const tick = (now: number) => {
81
92
  if (recordingCamera.value) return;
82
93
  const t = duration === 0 ? 1 : Math.min(1, (now - start) / duration);
83
94
  viewport.value = interpolateCamera(from, target, t, area.width, area.height, stop.easing, stop.pullback);
84
- if (t < 1) frame = requestAnimationFrame(tick);
95
+ if (t < 1) frame.current = requestAnimationFrame(tick);
85
96
  };
86
- frame = requestAnimationFrame(tick);
87
- return () => cancelAnimationFrame(frame);
97
+ frame.current = requestAnimationFrame(tick);
98
+ return () => cancelAnimationFrame(frame.current);
88
99
  }, [active, index, tour]);
89
100
  useEffect(() => {
90
101
  if (!active) return;
91
102
  const key = (event: KeyboardEvent) => {
92
103
  event.stopImmediatePropagation();
104
+ if (event.target instanceof HTMLButtonElement && ['Enter', ' '].includes(event.key)) return;
93
105
  if (['Escape', 'ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', ' '].includes(event.key))
94
106
  event.preventDefault();
95
107
  if (event.key === 'Escape') presenting.value = false;
@@ -100,9 +112,27 @@ export function Presentation() {
100
112
  document.addEventListener('keydown', key, true);
101
113
  return () => document.removeEventListener('keydown', key, true);
102
114
  }, [active, tour]);
103
- return active && error ? (
104
- <div class="presentation-error" role="alert">
105
- {error}
106
- </div>
115
+ return active ? (
116
+ <>
117
+ {!recordingCamera.value && (
118
+ <div
119
+ class="presentation-navigation"
120
+ ref={navigationRef}
121
+ onPointerDown={(event) => {
122
+ if (event.button === 1 || event.button === 2) cancelAnimationFrame(frame.current);
123
+ }}
124
+ onContextMenu={(event) => event.preventDefault()}
125
+ >
126
+ <button type="button" class="presentation-exit" onClick={() => (presenting.value = false)}>
127
+ Exit presentation
128
+ </button>
129
+ </div>
130
+ )}
131
+ {error && (
132
+ <div class="presentation-error" role="alert">
133
+ {error}
134
+ </div>
135
+ )}
136
+ </>
107
137
  ) : null;
108
138
  }
@@ -8,7 +8,6 @@ import {
8
8
  clearSelection,
9
9
  contextPinnedNodeIds,
10
10
  distributeSelection,
11
- removeNode,
12
11
  selectedNodeIds,
13
12
  } from '../state/canvas-store';
14
13
  import { createEdgeFromClient, createGroupFromClient, removeNodeFromClient } from '../state/intent-bridge';
@@ -51,7 +50,6 @@ export function SelectionBar() {
51
50
  const handleDelete = useCallback(() => {
52
51
  const ids = Array.from(selectedNodeIds.value);
53
52
  for (const id of ids) {
54
- removeNode(id);
55
53
  void removeNodeFromClient(id);
56
54
  }
57
55
  clearSelection();
@@ -11,7 +11,6 @@ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeSta
11
11
  const boardId = activeBoardId.value;
12
12
  const [job, setJob] = useState<DocumentImport | null>(null);
13
13
  const [review, setReview] = useState(expanded);
14
- const [consent, setConsent] = useState(false);
15
14
  const [busy, setBusy] = useState(false);
16
15
  useEffect(() => {
17
16
  let current = true;
@@ -53,7 +52,6 @@ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeSta
53
52
  setBusy(false);
54
53
  if (result?.import) {
55
54
  setJob(result.import);
56
- setConsent(false);
57
55
  setReview(false);
58
56
  if (expanded && action === 'commit') collapseExpandedNode();
59
57
  }
@@ -74,22 +72,19 @@ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeSta
74
72
  Download original · {Math.ceil(Number(node.data.size ?? 0) / 1024)} KB
75
73
  </a>
76
74
  {job?.reason && <p>{job.reason}</p>}
77
- {canRequest && !consent && (
78
- <button type="button" class="home-board-action" onClick={() => setConsent(true)}>
79
- Ask agent to import
80
- </button>
81
- )}
82
- {consent && (
75
+ {canRequest && (
83
76
  <>
84
- <p>
85
- The connected agent may send this file to its model provider. It needs file-reading tools and access to the
86
- original. Canvas does not convert files itself.
77
+ <p id={`import-consent-${node.id}`}>
78
+ Asking the agent to import allows it to process this file with its tools and model provider.
87
79
  </p>
88
- <button type="button" class="home-board-action" disabled={busy} onClick={() => void act('request')}>
89
- Allow processing & request
90
- </button>
91
- <button type="button" class="home-board-action" onClick={() => setConsent(false)}>
92
- Not now
80
+ <button
81
+ type="button"
82
+ class="attachment-button is-primary"
83
+ aria-describedby={`import-consent-${node.id}`}
84
+ disabled={busy}
85
+ onClick={() => void act('request')}
86
+ >
87
+ Ask agent to import
93
88
  </button>
94
89
  </>
95
90
  )}
@@ -98,17 +93,17 @@ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeSta
98
93
  No automatic extraction. Ask your connected agent to check its pending steering or use canvas_import list.
99
94
  </p>
100
95
  )}
101
- {job?.status === 'drafted' && (
96
+ {job?.status === 'drafted' && !review && (
102
97
  <button
103
98
  type="button"
104
- class="home-board-action"
99
+ class="attachment-button is-primary"
105
100
  onClick={() => (expanded ? setReview(true) : expandNode(node.id))}
106
101
  >
107
102
  Review Markdown · {job.sections.length} {job.sections.length === 1 ? 'card' : 'cards'}
108
103
  </button>
109
104
  )}
110
105
  {job && ['requested', 'drafted'].includes(job.status) && (
111
- <button type="button" class="home-board-action" disabled={busy} onClick={() => void act('cancel')}>
106
+ <button type="button" class="attachment-button" disabled={busy} onClick={() => void act('cancel')}>
112
107
  Cancel import
113
108
  </button>
114
109
  )}
@@ -130,17 +125,19 @@ export function AttachmentNode({ node, expanded = false }: { node: CanvasNodeSta
130
125
  <pre>{section.markdown}</pre>
131
126
  </section>
132
127
  ))}
133
- <button type="button" class="home-board-action" onClick={() => setReview(false)}>
134
- Close review
135
- </button>
136
- <button
137
- type="button"
138
- class="home-view-new"
139
- disabled={busy || boardId !== job.boardId}
140
- onClick={() => void act('commit')}
141
- >
142
- Add to board
143
- </button>
128
+ <div class="attachment-review-actions">
129
+ <button type="button" class="attachment-button" onClick={() => setReview(false)}>
130
+ Close review
131
+ </button>
132
+ <button
133
+ type="button"
134
+ class="attachment-button is-primary"
135
+ disabled={busy || boardId !== job.boardId}
136
+ onClick={() => void act('commit')}
137
+ >
138
+ Add to board
139
+ </button>
140
+ </div>
144
141
  </div>
145
142
  )}
146
143
  </div>
@@ -274,7 +274,9 @@ function enqueueToast(entry: AttentionEntry): void {
274
274
 
275
275
  /** Show a transient toast from arbitrary client code (e.g. AX action feedback). */
276
276
  export function showToast(tone: AttentionTone, title: string, detail = '', nodeIds: string[] = []): void {
277
- enqueueToast(makeEntry(tone, title, detail, nodeIds));
277
+ const entry = makeEntry(tone, title, detail, nodeIds);
278
+ pushAttentionHistory(entry);
279
+ enqueueToast(entry);
278
280
  }
279
281
 
280
282
  function pulseNodes(nodeIds: string[]): void {
@@ -41,7 +41,7 @@ import {
41
41
  import { agentActivity, applyPresenceSnapshot, sessionActive } from './presence-store';
42
42
  import { applyHumanSnapshot, startHumanPresence } from './human-store';
43
43
  import type { HumanPresenceSnapshot } from '../../shared/human-presence.js';
44
- import { applySessionReceipt, refreshTimeline } from './session-store';
44
+ import { applySessionReceipt, dismissSessionReceipt, refreshTimeline } from './session-store';
45
45
  import { initSessionThemeOverride, themeOverrideActive } from './theme-override';
46
46
  import { DEFAULT_POSITIONS, makeNodeState } from './node-factory';
47
47
  import { invalidateTokenCache } from '../theme/tokens';
@@ -479,6 +479,8 @@ function resyncForBoardSwitch(): void {
479
479
  hasInitialServerLayout.value = false;
480
480
  resetIntents();
481
481
  resetCanvasInteractionState();
482
+ resetAttentionBridge();
483
+ dismissSessionReceipt();
482
484
  axSurfaceState.value = null;
483
485
  void refreshAxSurface();
484
486
  const layoutAtRequest = layoutRevision;
@@ -4784,7 +4784,7 @@ html.is-node-resizing .ext-app-preview-catcher {
4784
4784
  position: absolute;
4785
4785
  top: 18px;
4786
4786
  right: 18px;
4787
- max-width: min(260px, calc(100% - 24px));
4787
+ max-width: min(360px, calc(100% - 36px));
4788
4788
  display: inline-flex;
4789
4789
  align-items: center;
4790
4790
  gap: 8px;
@@ -4792,7 +4792,7 @@ html.is-node-resizing .ext-app-preview-catcher {
4792
4792
  background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
4793
4793
  backdrop-filter: blur(14px);
4794
4794
  border: 1px solid color-mix(in srgb, var(--c-line) 76%, var(--c-warn) 24%);
4795
- border-radius: 999px;
4795
+ border-radius: 12px;
4796
4796
  box-shadow: 0 8px 22px var(--c-shadow);
4797
4797
  color: var(--c-text);
4798
4798
  font: inherit;
@@ -4831,15 +4831,27 @@ html.is-node-resizing .ext-app-preview-catcher {
4831
4831
  box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 28%, transparent);
4832
4832
  }
4833
4833
 
4834
+ .attention-toast-copy {
4835
+ display: grid;
4836
+ gap: 4px;
4837
+ min-width: 0;
4838
+ text-align: left;
4839
+ overflow-wrap: anywhere;
4840
+ }
4841
+
4842
+ .attention-toast-detail {
4843
+ color: var(--c-text-soft);
4844
+ font-size: 12px;
4845
+ line-height: 1.45;
4846
+ max-height: 9em;
4847
+ overflow: auto;
4848
+ }
4849
+
4834
4850
  .attention-toast-title {
4835
4851
  font-size: 12px;
4836
4852
  font-weight: 600;
4837
4853
  line-height: 1.2;
4838
4854
  letter-spacing: -0.005em;
4839
- white-space: nowrap;
4840
- overflow: hidden;
4841
- text-overflow: ellipsis;
4842
- max-width: 200px;
4843
4855
  }
4844
4856
 
4845
4857
  .attention-history {
@@ -6841,12 +6853,23 @@ html.is-node-dragging .intent-line-layer {
6841
6853
  padding: 4px 12px;
6842
6854
  cursor: pointer;
6843
6855
  }
6844
- .is-presenting > :not(.app-main):not(.presentation-error),
6856
+ .is-presenting > :not(.app-main):not(.presentation-error):not(.presentation-navigation),
6845
6857
  .is-presenting .app-main > :not(.canvas-region),
6846
6858
  .is-presenting .canvas-region > :not(.canvas-viewport) {
6847
6859
  display: none !important;
6848
6860
  }
6849
6861
  .is-presenting .canvas-viewport { pointer-events: none; }
6862
+ .presentation-navigation {
6863
+ position: fixed;
6864
+ inset: 0;
6865
+ z-index: 99;
6866
+ }
6867
+ .presentation-exit {
6868
+ position: absolute;
6869
+ top: 16px;
6870
+ right: 16px;
6871
+ box-shadow: 0 2px 12px var(--c-shadow);
6872
+ }
6850
6873
  .presentation-error {
6851
6874
  position: fixed;
6852
6875
  bottom: 16px;
@@ -7104,10 +7127,17 @@ html.is-node-dragging .intent-line-layer {
7104
7127
  }
7105
7128
  .export-dialog-links {
7106
7129
  display: flex;
7130
+ flex-wrap: wrap;
7107
7131
  gap: 12px;
7132
+ margin-top: 12px;
7108
7133
  }
7109
7134
  .export-dialog-links a {
7110
- color: var(--c-accent);
7135
+ display: inline-flex;
7136
+ align-items: center;
7137
+ justify-content: center;
7138
+ box-sizing: border-box;
7139
+ text-decoration: none;
7140
+ word-break: normal;
7111
7141
  }
7112
7142
 
7113
7143
  /* ── Home categories ── */
@@ -7267,9 +7297,42 @@ html.is-node-dragging .intent-line-layer {
7267
7297
  .attachment-node p { margin: 0; color: var(--c-text-soft); line-height: 1.5; }
7268
7298
  .attachment-node a, .md-import-source a { color: var(--c-accent); }
7269
7299
  .attachment-status { font-weight: 600; }
7270
- .attachment-node .home-board-action { border-color: var(--c-line); color: var(--c-text); min-height: 34px; }
7271
- .attachment-node .home-board-action:hover { background: var(--c-panel); border-color: var(--c-accent); }
7300
+ .attachment-button,
7301
+ .presentation-exit,
7302
+ .export-dialog-links a {
7303
+ min-height: 38px;
7304
+ padding: 8px 14px;
7305
+ border: 1px solid var(--c-text-soft);
7306
+ border-radius: 8px;
7307
+ background: var(--c-panel);
7308
+ color: var(--c-text);
7309
+ font: inherit;
7310
+ font-weight: 600;
7311
+ cursor: pointer;
7312
+ }
7313
+ .attachment-button.is-primary,
7314
+ .export-dialog-links a.is-primary { background: var(--c-accent); border-color: var(--c-accent); color: var(--c-bg); }
7315
+ .attachment-button:hover:not(:disabled),
7316
+ .presentation-exit:hover,
7317
+ .export-dialog-links a:hover { filter: brightness(1.12); }
7318
+ .attachment-button:focus-visible,
7319
+ .presentation-exit:focus-visible,
7320
+ .export-dialog-links a:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 3px; }
7321
+ .attachment-button:disabled { opacity: 0.5; cursor: default; }
7272
7322
  .attachment-review { width: 100%; border-top: 1px solid var(--c-line); padding-top: 12px; }
7323
+ .attachment-review-actions {
7324
+ position: sticky;
7325
+ bottom: -16px;
7326
+ display: flex;
7327
+ flex-wrap: wrap;
7328
+ justify-content: flex-end;
7329
+ gap: 12px;
7330
+ margin-top: 24px;
7331
+ padding: 16px 0;
7332
+ border-top: 1px solid var(--c-line);
7333
+ background: var(--c-panel);
7334
+ z-index: 1;
7335
+ }
7273
7336
  .attachment-review pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 var(--mono); padding: 12px; background: var(--c-bg); border-radius: 8px; }
7274
7337
  .attachment-review h4 { margin-bottom: 6px; }
7275
7338
  .md-import-source { padding: 6px 12px; font-size: 11px; overflow-wrap: anywhere; color: var(--c-text-soft); border-top: 1px solid var(--c-line); }
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { canvasState } from '../../canvas-state.js';
3
- import { addCanvasNode } from '../../canvas-operations.js';
3
+ import { addCanvasNode, createCanvasGroup } from '../../canvas-operations.js';
4
+ import { findOpenCanvasPosition, overlapsAny } from '../../../shared/placement.js';
5
+ import { GROUP_PAD, GROUP_TITLEBAR_HEIGHT } from '../../placement.js';
4
6
  import {
5
7
  MAX_IMPORT_AGENT_DESCRIPTION_LENGTH,
6
8
  MAX_IMPORT_REASON_LENGTH,
@@ -192,6 +194,17 @@ const commit = defineOperation({
192
194
  throw new OperationError('Open the captured source board before committing this import.', 409);
193
195
  const a = canvasState.getAttachment(job.attachmentId);
194
196
  if (!a) throw new OperationError('Attachment not found.', 404);
197
+ const grouped = job.sections.length > 1;
198
+ const padX = grouped ? GROUP_PAD : 0;
199
+ const padY = grouped ? GROUP_PAD + GROUP_TITLEBAR_HEIGHT : 0;
200
+ const width = Math.min(job.sections.length, 2) * 440 - 40 + padX * 2;
201
+ const height = Math.ceil(job.sections.length / 2) * 340 - 60 + padY + padX;
202
+ // Reserve the whole grid, including its group frame, against current board
203
+ // geometry. Another import or edit may have occupied the drop-time anchor.
204
+ const obstacles = canvasState.getLayout().nodes;
205
+ const origin = overlapsAny(job.position, width, height, obstacles, 48)
206
+ ? findOpenCanvasPosition(obstacles, width, height, 48)
207
+ : job.position;
195
208
  const nodeIds = job.sections.map(
196
209
  (s, index) =>
197
210
  addCanvasNode({
@@ -201,17 +214,20 @@ const commit = defineOperation({
201
214
  data: {
202
215
  source: { attachmentId: a.id, filename: a.name, ...(s.reference ? { reference: s.reference } : {}) },
203
216
  },
204
- x: job.position.x + (index % 2) * 440,
205
- y: job.position.y + Math.floor(index / 2) * 340,
217
+ x: origin.x + padX + (index % 2) * 440,
218
+ y: origin.y + padY + Math.floor(index / 2) * 340,
206
219
  defaultWidth: 400,
207
220
  defaultHeight: 280,
208
221
  fileMode: 'auto',
209
222
  }).id,
210
223
  );
224
+ let groupId: string | undefined;
211
225
  try {
226
+ if (grouped) groupId = createCanvasGroup({ title: a.name, childIds: nodeIds }).id;
212
227
  const committed = canvasState.commitDocumentImport(i.id, nodeIds);
213
228
  return { ok: true, nodeIds, import: committed };
214
229
  } catch (error) {
230
+ if (groupId) canvasState.removeNode(groupId);
215
231
  for (const nodeId of nodeIds) canvasState.removeNode(nodeId);
216
232
  throw error;
217
233
  }