@retor/react-native 0.3.6 → 0.4.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.
package/dist/index.d.mts CHANGED
@@ -17,6 +17,8 @@ interface RetorTag {
17
17
  tagType?: string;
18
18
  iconName?: string;
19
19
  objectId?: string;
20
+ /** 0..1 position along the parent line. Set on notes; computed from index for control tags. */
21
+ progress?: number;
20
22
  }
21
23
  interface RetorLine {
22
24
  _id: string;
@@ -43,6 +45,15 @@ interface InitPayload {
43
45
  interface LineProgressPayload {
44
46
  progress: number;
45
47
  closestTagId: string | null;
48
+ /** Point on the line at current progress (project-local space). */
49
+ targetPosition?: {
50
+ x: number;
51
+ y: number;
52
+ z: number;
53
+ } | null;
54
+ /** Distance along the line from the start.
55
+ * In meters if GPS is configured on the project, else project-local units. */
56
+ distanceFromStart?: number | null;
46
57
  }
47
58
  interface ViewerHandle {
48
59
  openLine: (lineId: string) => void;
@@ -55,29 +66,43 @@ interface ViewerHandle {
55
66
  interface NoteSubmitPayload {
56
67
  text: string;
57
68
  isPrivate: boolean;
69
+ /** The tag that was active when the + button was pressed. */
58
70
  tagId: string | null;
59
71
  lineId: string | null;
72
+ /**
73
+ * Point on the line at the scroll position when the + button was pressed,
74
+ * in project-local space. This is where the note visually anchors.
75
+ */
60
76
  position: {
61
77
  x: number;
62
78
  y: number;
63
79
  z: number;
64
80
  } | null;
81
+ /** Scroll progress along the line (0..1) when the + was pressed. */
82
+ progress: number;
83
+ /** Distance from the start of the line when the + was pressed.
84
+ * In meters if GPS is configured on the project, else project-local units. */
85
+ distanceFromStart: number | null;
65
86
  }
87
+ /**
88
+ * Stable data + commands. Updates only when meaningful things change
89
+ * (line opens/closes, notes loaded, project changes). Does NOT update per frame.
90
+ */
66
91
  interface RetorBridgeContextValue {
67
92
  project: RetorProject | null;
68
93
  lines: RetorLine[];
69
94
  activeLineId: string | null;
70
95
  activeLine: RetorLine | null;
71
- closestTagId: string | null;
72
- progress: number;
73
- isPlaying: boolean;
96
+ /** Notes for the active line, pushed by the bridge from Convex. */
97
+ lineNotes: RetorTag[];
98
+ /** Notes injected by the consumer via the <Notes> sentinel. */
99
+ externalNotes: RetorTag[];
74
100
  isAddNoteOpen: boolean;
75
101
  addNoteTagId: string | null;
76
102
  controls: ViewerHandle;
77
103
  openAddNote: (tagId?: string) => void;
78
104
  closeAddNote: () => void;
79
105
  submitNote: (text: string, isPrivate?: boolean) => void;
80
- onNoteSubmit?: (payload: NoteSubmitPayload) => void;
81
106
  }
82
107
  declare function useRetorBridge(): RetorBridgeContextValue;
83
108
  /** Returns the array of all lines in the current project. */
@@ -91,11 +116,18 @@ declare function useProject(): RetorProject | null;
91
116
  declare function useActiveLine(): RetorLine | null;
92
117
  /**
93
118
  * Returns scroll progress (0..1) and the id of the closest tag along the
94
- * currently active line. Both are null when no line is open.
119
+ * currently active line. Components using this re-render on every frame
120
+ * during camera scroll — only call from leaves (tag items, progress rings).
95
121
  */
96
122
  declare function useLineProgress(): {
97
123
  progress: number;
98
124
  closestTagId: string | null;
125
+ targetPosition: {
126
+ x: number;
127
+ y: number;
128
+ z: number;
129
+ } | null;
130
+ distanceFromStart: number | null;
99
131
  };
100
132
  /**
101
133
  * Returns the autoplay state and controls.
package/dist/index.d.ts CHANGED
@@ -17,6 +17,8 @@ interface RetorTag {
17
17
  tagType?: string;
18
18
  iconName?: string;
19
19
  objectId?: string;
20
+ /** 0..1 position along the parent line. Set on notes; computed from index for control tags. */
21
+ progress?: number;
20
22
  }
21
23
  interface RetorLine {
22
24
  _id: string;
@@ -43,6 +45,15 @@ interface InitPayload {
43
45
  interface LineProgressPayload {
44
46
  progress: number;
45
47
  closestTagId: string | null;
48
+ /** Point on the line at current progress (project-local space). */
49
+ targetPosition?: {
50
+ x: number;
51
+ y: number;
52
+ z: number;
53
+ } | null;
54
+ /** Distance along the line from the start.
55
+ * In meters if GPS is configured on the project, else project-local units. */
56
+ distanceFromStart?: number | null;
46
57
  }
47
58
  interface ViewerHandle {
48
59
  openLine: (lineId: string) => void;
@@ -55,29 +66,43 @@ interface ViewerHandle {
55
66
  interface NoteSubmitPayload {
56
67
  text: string;
57
68
  isPrivate: boolean;
69
+ /** The tag that was active when the + button was pressed. */
58
70
  tagId: string | null;
59
71
  lineId: string | null;
72
+ /**
73
+ * Point on the line at the scroll position when the + button was pressed,
74
+ * in project-local space. This is where the note visually anchors.
75
+ */
60
76
  position: {
61
77
  x: number;
62
78
  y: number;
63
79
  z: number;
64
80
  } | null;
81
+ /** Scroll progress along the line (0..1) when the + was pressed. */
82
+ progress: number;
83
+ /** Distance from the start of the line when the + was pressed.
84
+ * In meters if GPS is configured on the project, else project-local units. */
85
+ distanceFromStart: number | null;
65
86
  }
87
+ /**
88
+ * Stable data + commands. Updates only when meaningful things change
89
+ * (line opens/closes, notes loaded, project changes). Does NOT update per frame.
90
+ */
66
91
  interface RetorBridgeContextValue {
67
92
  project: RetorProject | null;
68
93
  lines: RetorLine[];
69
94
  activeLineId: string | null;
70
95
  activeLine: RetorLine | null;
71
- closestTagId: string | null;
72
- progress: number;
73
- isPlaying: boolean;
96
+ /** Notes for the active line, pushed by the bridge from Convex. */
97
+ lineNotes: RetorTag[];
98
+ /** Notes injected by the consumer via the <Notes> sentinel. */
99
+ externalNotes: RetorTag[];
74
100
  isAddNoteOpen: boolean;
75
101
  addNoteTagId: string | null;
76
102
  controls: ViewerHandle;
77
103
  openAddNote: (tagId?: string) => void;
78
104
  closeAddNote: () => void;
79
105
  submitNote: (text: string, isPrivate?: boolean) => void;
80
- onNoteSubmit?: (payload: NoteSubmitPayload) => void;
81
106
  }
82
107
  declare function useRetorBridge(): RetorBridgeContextValue;
83
108
  /** Returns the array of all lines in the current project. */
@@ -91,11 +116,18 @@ declare function useProject(): RetorProject | null;
91
116
  declare function useActiveLine(): RetorLine | null;
92
117
  /**
93
118
  * Returns scroll progress (0..1) and the id of the closest tag along the
94
- * currently active line. Both are null when no line is open.
119
+ * currently active line. Components using this re-render on every frame
120
+ * during camera scroll — only call from leaves (tag items, progress rings).
95
121
  */
96
122
  declare function useLineProgress(): {
97
123
  progress: number;
98
124
  closestTagId: string | null;
125
+ targetPosition: {
126
+ x: number;
127
+ y: number;
128
+ z: number;
129
+ } | null;
130
+ distanceFromStart: number | null;
99
131
  };
100
132
  /**
101
133
  * Returns the autoplay state and controls.
package/dist/index.js CHANGED
@@ -54,6 +54,13 @@ module.exports = __toCommonJS(index_exports);
54
54
  // src/context.tsx
55
55
  var import_react = __toESM(require("react"));
56
56
  var RetorBridgeContext = (0, import_react.createContext)(null);
57
+ var RetorProgressContext = (0, import_react.createContext)({
58
+ progress: 0,
59
+ closestTagId: null,
60
+ targetPosition: null,
61
+ distanceFromStart: null,
62
+ isPlaying: false
63
+ });
57
64
  function noop() {
58
65
  }
59
66
  var noopHandle = {
@@ -68,9 +75,8 @@ var fallback = {
68
75
  lines: [],
69
76
  activeLineId: null,
70
77
  activeLine: null,
71
- closestTagId: null,
72
- progress: 0,
73
- isPlaying: false,
78
+ lineNotes: [],
79
+ externalNotes: [],
74
80
  isAddNoteOpen: false,
75
81
  addNoteTagId: null,
76
82
  controls: noopHandle,
@@ -81,6 +87,9 @@ var fallback = {
81
87
  function useRetorBridge() {
82
88
  return (0, import_react.useContext)(RetorBridgeContext) ?? fallback;
83
89
  }
90
+ function useRetorProgress() {
91
+ return (0, import_react.useContext)(RetorProgressContext);
92
+ }
84
93
  function useLines() {
85
94
  return useRetorBridge().lines;
86
95
  }
@@ -91,11 +100,15 @@ function useActiveLine() {
91
100
  return useRetorBridge().activeLine;
92
101
  }
93
102
  function useLineProgress() {
94
- const { progress, closestTagId } = useRetorBridge();
95
- return (0, import_react.useMemo)(() => ({ progress, closestTagId }), [progress, closestTagId]);
103
+ const { progress, closestTagId, targetPosition, distanceFromStart } = useRetorProgress();
104
+ return (0, import_react.useMemo)(
105
+ () => ({ progress, closestTagId, targetPosition, distanceFromStart }),
106
+ [progress, closestTagId, targetPosition, distanceFromStart]
107
+ );
96
108
  }
97
109
  function useAutoplay() {
98
- const { isPlaying, controls } = useRetorBridge();
110
+ const { isPlaying } = useRetorProgress();
111
+ const { controls } = useRetorBridge();
99
112
  return (0, import_react.useMemo)(() => ({
100
113
  isPlaying,
101
114
  toggle: () => controls.toggleAutoplay(),
@@ -116,6 +129,9 @@ function useAddNote() {
116
129
  function RetorBridgeProvider({ value, children }) {
117
130
  return /* @__PURE__ */ import_react.default.createElement(RetorBridgeContext.Provider, { value }, children);
118
131
  }
132
+ function RetorProgressProvider({ value, children }) {
133
+ return /* @__PURE__ */ import_react.default.createElement(RetorProgressContext.Provider, { value }, children);
134
+ }
119
135
 
120
136
  // src/Viewer.tsx
121
137
  var import_react2 = __toESM(require("react"));
@@ -194,9 +210,12 @@ var Viewer = (0, import_react2.forwardRef)(function Viewer2({ projectId, id = "d
194
210
  const [activeLineId, setActiveLineId] = (0, import_react2.useState)(null);
195
211
  const [closestTagId, setClosestTagId] = (0, import_react2.useState)(null);
196
212
  const [progress, setProgress] = (0, import_react2.useState)(0);
213
+ const [targetPosition, setTargetPosition] = (0, import_react2.useState)(null);
214
+ const [distanceFromStart, setDistanceFromStart] = (0, import_react2.useState)(null);
197
215
  const [isPlaying, setIsPlaying] = (0, import_react2.useState)(false);
198
216
  const [isAddNoteOpen, setIsAddNoteOpen] = (0, import_react2.useState)(false);
199
217
  const [addNoteTagId, setAddNoteTagId] = (0, import_react2.useState)(null);
218
+ const [lineNotes, setLineNotes] = (0, import_react2.useState)([]);
200
219
  const uri = (0, import_react2.useMemo)(() => `${baseUrl}/p/${projectId}?vanilla=true`, [baseUrl, projectId]);
201
220
  const send = (0, import_react2.useCallback)((type, payload) => {
202
221
  const message = JSON.stringify({ source: "retor-host", type, payload });
@@ -235,8 +254,11 @@ var Viewer = (0, import_react2.forwardRef)(function Viewer2({ projectId, id = "d
235
254
  const closestTagIdRef = (0, import_react2.useRef)(closestTagId);
236
255
  const addNoteTagIdRef = (0, import_react2.useRef)(addNoteTagId);
237
256
  const activeLineIdRef = (0, import_react2.useRef)(activeLineId);
238
- const linesRef = (0, import_react2.useRef)(lines);
239
257
  const onNoteSubmitRef = (0, import_react2.useRef)(onNoteSubmit);
258
+ const noteSnapshotRef = (0, import_react2.useRef)({ progress: 0, targetPosition: null, distanceFromStart: null });
259
+ const progressRef = (0, import_react2.useRef)(progress);
260
+ const targetPositionRef = (0, import_react2.useRef)(targetPosition);
261
+ const distanceFromStartRef = (0, import_react2.useRef)(distanceFromStart);
240
262
  (0, import_react2.useEffect)(() => {
241
263
  closestTagIdRef.current = closestTagId;
242
264
  }, [closestTagId]);
@@ -246,14 +268,25 @@ var Viewer = (0, import_react2.forwardRef)(function Viewer2({ projectId, id = "d
246
268
  (0, import_react2.useEffect)(() => {
247
269
  activeLineIdRef.current = activeLineId;
248
270
  }, [activeLineId]);
249
- (0, import_react2.useEffect)(() => {
250
- linesRef.current = lines;
251
- }, [lines]);
252
271
  (0, import_react2.useEffect)(() => {
253
272
  onNoteSubmitRef.current = onNoteSubmit;
254
273
  }, [onNoteSubmit]);
274
+ (0, import_react2.useEffect)(() => {
275
+ progressRef.current = progress;
276
+ }, [progress]);
277
+ (0, import_react2.useEffect)(() => {
278
+ targetPositionRef.current = targetPosition;
279
+ }, [targetPosition]);
280
+ (0, import_react2.useEffect)(() => {
281
+ distanceFromStartRef.current = distanceFromStart;
282
+ }, [distanceFromStart]);
255
283
  const openAddNote = (0, import_react2.useCallback)((tagId) => {
256
284
  setAddNoteTagId(tagId ?? closestTagIdRef.current ?? null);
285
+ noteSnapshotRef.current = {
286
+ progress: progressRef.current,
287
+ targetPosition: targetPositionRef.current,
288
+ distanceFromStart: distanceFromStartRef.current
289
+ };
257
290
  setIsAddNoteOpen(true);
258
291
  }, []);
259
292
  const closeAddNote = (0, import_react2.useCallback)(() => {
@@ -262,17 +295,23 @@ var Viewer = (0, import_react2.forwardRef)(function Viewer2({ projectId, id = "d
262
295
  const submitNote = (0, import_react2.useCallback)((text, isPrivate = true) => {
263
296
  const tagId = addNoteTagIdRef.current;
264
297
  const lineId = activeLineIdRef.current;
265
- const tag = linesRef.current.find((l) => l._id === lineId)?.tags.find((t) => t._id === tagId);
298
+ const snap = noteSnapshotRef.current;
266
299
  const payload = {
267
300
  text,
268
301
  isPrivate,
269
302
  tagId,
270
303
  lineId,
271
- position: tag?.position ?? null
304
+ position: snap.targetPosition,
305
+ progress: snap.progress,
306
+ distanceFromStart: snap.distanceFromStart
272
307
  };
273
- onNoteSubmitRef.current?.(payload);
308
+ if (onNoteSubmitRef.current) {
309
+ onNoteSubmitRef.current(payload);
310
+ } else {
311
+ send("note-submit", payload);
312
+ }
274
313
  setIsAddNoteOpen(false);
275
- }, []);
314
+ }, [send]);
276
315
  const handleMessage = (0, import_react2.useCallback)(
277
316
  (event) => {
278
317
  let data = null;
@@ -303,15 +342,26 @@ var Viewer = (0, import_react2.forwardRef)(function Viewer2({ projectId, id = "d
303
342
  case "line-close":
304
343
  setActiveLineId(null);
305
344
  setIsPlaying(false);
345
+ setLineNotes([]);
306
346
  onLineClose?.();
307
347
  break;
308
348
  case "line-progress": {
309
349
  const payload = data.payload;
310
350
  setProgress(payload.progress);
311
351
  setClosestTagId(payload.closestTagId);
352
+ setTargetPosition(payload.targetPosition ?? null);
353
+ setDistanceFromStart(payload.distanceFromStart ?? null);
312
354
  onLineProgress?.(payload);
313
355
  break;
314
356
  }
357
+ case "line-notes": {
358
+ const payload = data.payload;
359
+ setLineNotes(payload.notes ?? []);
360
+ break;
361
+ }
362
+ case "request-add-note":
363
+ openAddNote();
364
+ break;
315
365
  case "autoplay-state": {
316
366
  const payload = data.payload;
317
367
  setIsPlaying(!!payload.playing);
@@ -321,32 +371,35 @@ var Viewer = (0, import_react2.forwardRef)(function Viewer2({ projectId, id = "d
321
371
  onMessage?.(data.type, data.payload);
322
372
  }
323
373
  },
324
- [notes, send, onInit, onLineOpen, onLineClose, onLineProgress, onMessage]
374
+ [notes, send, onInit, onLineOpen, onLineClose, onLineProgress, onMessage, openAddNote]
325
375
  );
326
376
  const activeLine = (0, import_react2.useMemo)(
327
377
  () => lines.find((l) => l._id === activeLineId) ?? null,
328
378
  [lines, activeLineId]
329
379
  );
380
+ const externalNotes = (0, import_react2.useMemo)(() => notes ?? [], [notes]);
330
381
  const ctxValue = (0, import_react2.useMemo)(
331
382
  () => ({
332
383
  project,
333
384
  lines,
334
385
  activeLineId,
335
386
  activeLine,
336
- closestTagId,
337
- progress,
338
- isPlaying,
387
+ lineNotes,
388
+ externalNotes,
339
389
  isAddNoteOpen,
340
390
  addNoteTagId,
341
391
  controls,
342
392
  openAddNote,
343
393
  closeAddNote,
344
- submitNote,
345
- onNoteSubmit
394
+ submitNote
346
395
  }),
347
- [project, lines, activeLineId, activeLine, closestTagId, progress, isPlaying, isAddNoteOpen, addNoteTagId, controls, openAddNote, closeAddNote, submitNote, onNoteSubmit]
396
+ [project, lines, activeLineId, activeLine, lineNotes, externalNotes, isAddNoteOpen, addNoteTagId, controls, openAddNote, closeAddNote, submitNote]
397
+ );
398
+ const progressCtx = (0, import_react2.useMemo)(
399
+ () => ({ progress, closestTagId, targetPosition, distanceFromStart, isPlaying }),
400
+ [progress, closestTagId, targetPosition, distanceFromStart, isPlaying]
348
401
  );
349
- return /* @__PURE__ */ import_react2.default.createElement(RetorBridgeProvider, { value: ctxValue }, /* @__PURE__ */ import_react2.default.createElement(import_react_native.View, { style: [styles.root, style] }, /* @__PURE__ */ import_react2.default.createElement(
402
+ return /* @__PURE__ */ import_react2.default.createElement(RetorBridgeProvider, { value: ctxValue }, /* @__PURE__ */ import_react2.default.createElement(RetorProgressProvider, { value: progressCtx }, /* @__PURE__ */ import_react2.default.createElement(import_react_native.View, { style: [styles.root, style] }, /* @__PURE__ */ import_react2.default.createElement(
350
403
  import_react_native_webview.WebView,
351
404
  {
352
405
  ref: webviewRef,
@@ -359,7 +412,7 @@ var Viewer = (0, import_react2.forwardRef)(function Viewer2({ projectId, id = "d
359
412
  allowsInlineMediaPlayback: true,
360
413
  mediaPlaybackRequiresUserAction: false
361
414
  }
362
- ), children));
415
+ ), children)));
363
416
  });
364
417
  var styles = import_react_native.StyleSheet.create({
365
418
  root: { flex: 1, position: "relative" },
@@ -523,6 +576,24 @@ var import_react_native5 = require("react-native");
523
576
  var import_bottom_sheet3 = require("@gorhom/bottom-sheet");
524
577
  var import_react_native_svg = __toESM(require("react-native-svg"));
525
578
  var import_lucide_react_native2 = require("lucide-react-native");
579
+
580
+ // src/lineProgress.ts
581
+ function mergeLineTagsByProgress(controls, notes) {
582
+ const sortedControls = [...controls].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
583
+ const lastIdx = Math.max(1, sortedControls.length - 1);
584
+ const merged = [];
585
+ for (const c of sortedControls) {
586
+ const idx = c.index ?? 0;
587
+ merged.push({ ...c, _isNote: false, progress: idx / lastIdx });
588
+ }
589
+ for (const n of notes) {
590
+ merged.push({ ...n, _isNote: true, progress: n.progress ?? 0 });
591
+ }
592
+ merged.sort((a, b) => a.progress - b.progress);
593
+ return merged;
594
+ }
595
+
596
+ // src/LineDetailSheet.tsx
526
597
  var renderBackdrop2 = (props) => /* @__PURE__ */ import_react6.default.createElement(
527
598
  import_bottom_sheet3.BottomSheetBackdrop,
528
599
  {
@@ -593,7 +664,8 @@ function DefaultHeader({
593
664
  return /* @__PURE__ */ import_react6.default.createElement(import_react_native5.View, { style: styles3.header }, /* @__PURE__ */ import_react6.default.createElement(import_react_native5.View, { style: { flex: 1, minWidth: 0 } }, /* @__PURE__ */ import_react6.default.createElement(import_react_native5.Text, { style: styles3.title, numberOfLines: 1 }, line.name), line.subtitle && /* @__PURE__ */ import_react6.default.createElement(import_react_native5.Text, { style: styles3.subtitle, numberOfLines: 1 }, line.subtitle), line.description && /* @__PURE__ */ import_react6.default.createElement(import_react_native5.Text, { style: styles3.description, numberOfLines: minimized ? 2 : 4 }, line.description)), /* @__PURE__ */ import_react6.default.createElement(import_react_native5.View, { style: styles3.headerActions }, /* @__PURE__ */ import_react6.default.createElement(AutoplayButton, null), /* @__PURE__ */ import_react6.default.createElement(import_react_native5.Pressable, { style: styles3.iconBtn, onPress: onToggleMinimize }, minimized ? /* @__PURE__ */ import_react6.default.createElement(import_lucide_react_native2.ArrowUp, { size: 14, color: "rgba(255,255,255,0.6)" }) : /* @__PURE__ */ import_react6.default.createElement(import_lucide_react_native2.ArrowDown, { size: 14, color: "rgba(255,255,255,0.6)" }))));
594
665
  }
595
666
  function AutoplayButton() {
596
- const { isPlaying, progress, controls } = useRetorBridge();
667
+ const { controls } = useRetorBridge();
668
+ const { isPlaying, progress } = useRetorProgress();
597
669
  const r = 12.5;
598
670
  const c = 2 * Math.PI * r;
599
671
  return /* @__PURE__ */ import_react6.default.createElement(import_react_native5.Pressable, { style: styles3.iconBtn, onPress: () => controls.toggleAutoplay() }, /* @__PURE__ */ import_react6.default.createElement(import_react_native_svg.default, { width: 28, height: 28, style: import_react_native5.StyleSheet.absoluteFill }, /* @__PURE__ */ import_react6.default.createElement(
@@ -613,30 +685,34 @@ function AutoplayButton() {
613
685
  )), isPlaying ? /* @__PURE__ */ import_react6.default.createElement(import_lucide_react_native2.Pause, { size: 11, color: "white", fill: "white" }) : /* @__PURE__ */ import_react6.default.createElement(import_lucide_react_native2.Play, { size: 11, color: "white", fill: "white", style: { marginLeft: 1 } }));
614
686
  }
615
687
  function LineTagList({ children, listHeader }) {
616
- const { activeLine, closestTagId } = useRetorBridge();
688
+ const { activeLine, lineNotes, externalNotes } = useRetorBridge();
689
+ const { closestTagId } = useRetorProgress();
617
690
  const scrollRef = (0, import_react6.useRef)(null);
618
691
  const offsetsRef = (0, import_react6.useRef)(/* @__PURE__ */ new Map());
619
- const tags = (0, import_react6.useMemo)(
620
- () => (activeLine?.tags ?? []).filter((t) => t.name && t.name.trim().length > 0),
621
- [activeLine]
622
- );
692
+ const tags = (0, import_react6.useMemo)(() => {
693
+ if (!activeLine) return [];
694
+ const controls = (activeLine.tags ?? []).filter(
695
+ (t) => t.name && t.name.trim().length > 0
696
+ );
697
+ const allNotes = [...lineNotes, ...externalNotes].filter(
698
+ (n) => n.name && n.name.trim().length > 0
699
+ );
700
+ return mergeLineTagsByProgress(controls, allNotes);
701
+ }, [activeLine, lineNotes, externalNotes]);
623
702
  (0, import_react6.useEffect)(() => {
624
703
  offsetsRef.current = /* @__PURE__ */ new Map();
625
704
  }, [activeLine?._id]);
705
+ const lastScrolledIdRef = (0, import_react6.useRef)(null);
626
706
  (0, import_react6.useEffect)(() => {
627
- if (!closestTagId) return;
628
- let attempts = 0;
629
- const tick = () => {
707
+ if (!closestTagId || closestTagId === lastScrolledIdRef.current) return;
708
+ const t = setTimeout(() => {
630
709
  const y = offsetsRef.current.get(closestTagId);
631
710
  if (y != null) {
632
711
  scrollRef.current?.scrollTo?.({ y, animated: true });
633
- return;
712
+ lastScrolledIdRef.current = closestTagId;
634
713
  }
635
- attempts += 1;
636
- if (attempts < 10) raf = requestAnimationFrame(tick);
637
- };
638
- let raf = requestAnimationFrame(tick);
639
- return () => cancelAnimationFrame(raf);
714
+ }, 300);
715
+ return () => clearTimeout(t);
640
716
  }, [closestTagId]);
641
717
  if (!activeLine) return null;
642
718
  const handleItemLayout = (id, e) => {
package/dist/index.mjs CHANGED
@@ -1,6 +1,13 @@
1
1
  // src/context.tsx
2
2
  import React, { createContext, useContext, useMemo } from "react";
3
3
  var RetorBridgeContext = createContext(null);
4
+ var RetorProgressContext = createContext({
5
+ progress: 0,
6
+ closestTagId: null,
7
+ targetPosition: null,
8
+ distanceFromStart: null,
9
+ isPlaying: false
10
+ });
4
11
  function noop() {
5
12
  }
6
13
  var noopHandle = {
@@ -15,9 +22,8 @@ var fallback = {
15
22
  lines: [],
16
23
  activeLineId: null,
17
24
  activeLine: null,
18
- closestTagId: null,
19
- progress: 0,
20
- isPlaying: false,
25
+ lineNotes: [],
26
+ externalNotes: [],
21
27
  isAddNoteOpen: false,
22
28
  addNoteTagId: null,
23
29
  controls: noopHandle,
@@ -28,6 +34,9 @@ var fallback = {
28
34
  function useRetorBridge() {
29
35
  return useContext(RetorBridgeContext) ?? fallback;
30
36
  }
37
+ function useRetorProgress() {
38
+ return useContext(RetorProgressContext);
39
+ }
31
40
  function useLines() {
32
41
  return useRetorBridge().lines;
33
42
  }
@@ -38,11 +47,15 @@ function useActiveLine() {
38
47
  return useRetorBridge().activeLine;
39
48
  }
40
49
  function useLineProgress() {
41
- const { progress, closestTagId } = useRetorBridge();
42
- return useMemo(() => ({ progress, closestTagId }), [progress, closestTagId]);
50
+ const { progress, closestTagId, targetPosition, distanceFromStart } = useRetorProgress();
51
+ return useMemo(
52
+ () => ({ progress, closestTagId, targetPosition, distanceFromStart }),
53
+ [progress, closestTagId, targetPosition, distanceFromStart]
54
+ );
43
55
  }
44
56
  function useAutoplay() {
45
- const { isPlaying, controls } = useRetorBridge();
57
+ const { isPlaying } = useRetorProgress();
58
+ const { controls } = useRetorBridge();
46
59
  return useMemo(() => ({
47
60
  isPlaying,
48
61
  toggle: () => controls.toggleAutoplay(),
@@ -63,6 +76,9 @@ function useAddNote() {
63
76
  function RetorBridgeProvider({ value, children }) {
64
77
  return /* @__PURE__ */ React.createElement(RetorBridgeContext.Provider, { value }, children);
65
78
  }
79
+ function RetorProgressProvider({ value, children }) {
80
+ return /* @__PURE__ */ React.createElement(RetorProgressContext.Provider, { value }, children);
81
+ }
66
82
 
67
83
  // src/Viewer.tsx
68
84
  import React2, {
@@ -150,9 +166,12 @@ var Viewer = forwardRef(function Viewer2({ projectId, id = "default", baseUrl =
150
166
  const [activeLineId, setActiveLineId] = useState(null);
151
167
  const [closestTagId, setClosestTagId] = useState(null);
152
168
  const [progress, setProgress] = useState(0);
169
+ const [targetPosition, setTargetPosition] = useState(null);
170
+ const [distanceFromStart, setDistanceFromStart] = useState(null);
153
171
  const [isPlaying, setIsPlaying] = useState(false);
154
172
  const [isAddNoteOpen, setIsAddNoteOpen] = useState(false);
155
173
  const [addNoteTagId, setAddNoteTagId] = useState(null);
174
+ const [lineNotes, setLineNotes] = useState([]);
156
175
  const uri = useMemo2(() => `${baseUrl}/p/${projectId}?vanilla=true`, [baseUrl, projectId]);
157
176
  const send = useCallback((type, payload) => {
158
177
  const message = JSON.stringify({ source: "retor-host", type, payload });
@@ -191,8 +210,11 @@ var Viewer = forwardRef(function Viewer2({ projectId, id = "default", baseUrl =
191
210
  const closestTagIdRef = useRef(closestTagId);
192
211
  const addNoteTagIdRef = useRef(addNoteTagId);
193
212
  const activeLineIdRef = useRef(activeLineId);
194
- const linesRef = useRef(lines);
195
213
  const onNoteSubmitRef = useRef(onNoteSubmit);
214
+ const noteSnapshotRef = useRef({ progress: 0, targetPosition: null, distanceFromStart: null });
215
+ const progressRef = useRef(progress);
216
+ const targetPositionRef = useRef(targetPosition);
217
+ const distanceFromStartRef = useRef(distanceFromStart);
196
218
  useEffect(() => {
197
219
  closestTagIdRef.current = closestTagId;
198
220
  }, [closestTagId]);
@@ -202,14 +224,25 @@ var Viewer = forwardRef(function Viewer2({ projectId, id = "default", baseUrl =
202
224
  useEffect(() => {
203
225
  activeLineIdRef.current = activeLineId;
204
226
  }, [activeLineId]);
205
- useEffect(() => {
206
- linesRef.current = lines;
207
- }, [lines]);
208
227
  useEffect(() => {
209
228
  onNoteSubmitRef.current = onNoteSubmit;
210
229
  }, [onNoteSubmit]);
230
+ useEffect(() => {
231
+ progressRef.current = progress;
232
+ }, [progress]);
233
+ useEffect(() => {
234
+ targetPositionRef.current = targetPosition;
235
+ }, [targetPosition]);
236
+ useEffect(() => {
237
+ distanceFromStartRef.current = distanceFromStart;
238
+ }, [distanceFromStart]);
211
239
  const openAddNote = useCallback((tagId) => {
212
240
  setAddNoteTagId(tagId ?? closestTagIdRef.current ?? null);
241
+ noteSnapshotRef.current = {
242
+ progress: progressRef.current,
243
+ targetPosition: targetPositionRef.current,
244
+ distanceFromStart: distanceFromStartRef.current
245
+ };
213
246
  setIsAddNoteOpen(true);
214
247
  }, []);
215
248
  const closeAddNote = useCallback(() => {
@@ -218,17 +251,23 @@ var Viewer = forwardRef(function Viewer2({ projectId, id = "default", baseUrl =
218
251
  const submitNote = useCallback((text, isPrivate = true) => {
219
252
  const tagId = addNoteTagIdRef.current;
220
253
  const lineId = activeLineIdRef.current;
221
- const tag = linesRef.current.find((l) => l._id === lineId)?.tags.find((t) => t._id === tagId);
254
+ const snap = noteSnapshotRef.current;
222
255
  const payload = {
223
256
  text,
224
257
  isPrivate,
225
258
  tagId,
226
259
  lineId,
227
- position: tag?.position ?? null
260
+ position: snap.targetPosition,
261
+ progress: snap.progress,
262
+ distanceFromStart: snap.distanceFromStart
228
263
  };
229
- onNoteSubmitRef.current?.(payload);
264
+ if (onNoteSubmitRef.current) {
265
+ onNoteSubmitRef.current(payload);
266
+ } else {
267
+ send("note-submit", payload);
268
+ }
230
269
  setIsAddNoteOpen(false);
231
- }, []);
270
+ }, [send]);
232
271
  const handleMessage = useCallback(
233
272
  (event) => {
234
273
  let data = null;
@@ -259,15 +298,26 @@ var Viewer = forwardRef(function Viewer2({ projectId, id = "default", baseUrl =
259
298
  case "line-close":
260
299
  setActiveLineId(null);
261
300
  setIsPlaying(false);
301
+ setLineNotes([]);
262
302
  onLineClose?.();
263
303
  break;
264
304
  case "line-progress": {
265
305
  const payload = data.payload;
266
306
  setProgress(payload.progress);
267
307
  setClosestTagId(payload.closestTagId);
308
+ setTargetPosition(payload.targetPosition ?? null);
309
+ setDistanceFromStart(payload.distanceFromStart ?? null);
268
310
  onLineProgress?.(payload);
269
311
  break;
270
312
  }
313
+ case "line-notes": {
314
+ const payload = data.payload;
315
+ setLineNotes(payload.notes ?? []);
316
+ break;
317
+ }
318
+ case "request-add-note":
319
+ openAddNote();
320
+ break;
271
321
  case "autoplay-state": {
272
322
  const payload = data.payload;
273
323
  setIsPlaying(!!payload.playing);
@@ -277,32 +327,35 @@ var Viewer = forwardRef(function Viewer2({ projectId, id = "default", baseUrl =
277
327
  onMessage?.(data.type, data.payload);
278
328
  }
279
329
  },
280
- [notes, send, onInit, onLineOpen, onLineClose, onLineProgress, onMessage]
330
+ [notes, send, onInit, onLineOpen, onLineClose, onLineProgress, onMessage, openAddNote]
281
331
  );
282
332
  const activeLine = useMemo2(
283
333
  () => lines.find((l) => l._id === activeLineId) ?? null,
284
334
  [lines, activeLineId]
285
335
  );
336
+ const externalNotes = useMemo2(() => notes ?? [], [notes]);
286
337
  const ctxValue = useMemo2(
287
338
  () => ({
288
339
  project,
289
340
  lines,
290
341
  activeLineId,
291
342
  activeLine,
292
- closestTagId,
293
- progress,
294
- isPlaying,
343
+ lineNotes,
344
+ externalNotes,
295
345
  isAddNoteOpen,
296
346
  addNoteTagId,
297
347
  controls,
298
348
  openAddNote,
299
349
  closeAddNote,
300
- submitNote,
301
- onNoteSubmit
350
+ submitNote
302
351
  }),
303
- [project, lines, activeLineId, activeLine, closestTagId, progress, isPlaying, isAddNoteOpen, addNoteTagId, controls, openAddNote, closeAddNote, submitNote, onNoteSubmit]
352
+ [project, lines, activeLineId, activeLine, lineNotes, externalNotes, isAddNoteOpen, addNoteTagId, controls, openAddNote, closeAddNote, submitNote]
353
+ );
354
+ const progressCtx = useMemo2(
355
+ () => ({ progress, closestTagId, targetPosition, distanceFromStart, isPlaying }),
356
+ [progress, closestTagId, targetPosition, distanceFromStart, isPlaying]
304
357
  );
305
- return /* @__PURE__ */ React2.createElement(RetorBridgeProvider, { value: ctxValue }, /* @__PURE__ */ React2.createElement(View, { style: [styles.root, style] }, /* @__PURE__ */ React2.createElement(
358
+ return /* @__PURE__ */ React2.createElement(RetorBridgeProvider, { value: ctxValue }, /* @__PURE__ */ React2.createElement(RetorProgressProvider, { value: progressCtx }, /* @__PURE__ */ React2.createElement(View, { style: [styles.root, style] }, /* @__PURE__ */ React2.createElement(
306
359
  WebView,
307
360
  {
308
361
  ref: webviewRef,
@@ -315,7 +368,7 @@ var Viewer = forwardRef(function Viewer2({ projectId, id = "default", baseUrl =
315
368
  allowsInlineMediaPlayback: true,
316
369
  mediaPlaybackRequiresUserAction: false
317
370
  }
318
- ), children));
371
+ ), children)));
319
372
  });
320
373
  var styles = StyleSheet.create({
321
374
  root: { flex: 1, position: "relative" },
@@ -484,6 +537,24 @@ import {
484
537
  } from "@gorhom/bottom-sheet";
485
538
  import Svg, { Circle } from "react-native-svg";
486
539
  import { ArrowDown as ArrowDown2, ArrowUp as ArrowUp2, Pause, Play, Plus } from "lucide-react-native";
540
+
541
+ // src/lineProgress.ts
542
+ function mergeLineTagsByProgress(controls, notes) {
543
+ const sortedControls = [...controls].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
544
+ const lastIdx = Math.max(1, sortedControls.length - 1);
545
+ const merged = [];
546
+ for (const c of sortedControls) {
547
+ const idx = c.index ?? 0;
548
+ merged.push({ ...c, _isNote: false, progress: idx / lastIdx });
549
+ }
550
+ for (const n of notes) {
551
+ merged.push({ ...n, _isNote: true, progress: n.progress ?? 0 });
552
+ }
553
+ merged.sort((a, b) => a.progress - b.progress);
554
+ return merged;
555
+ }
556
+
557
+ // src/LineDetailSheet.tsx
487
558
  var renderBackdrop2 = (props) => /* @__PURE__ */ React6.createElement(
488
559
  BottomSheetBackdrop2,
489
560
  {
@@ -554,7 +625,8 @@ function DefaultHeader({
554
625
  return /* @__PURE__ */ React6.createElement(View5, { style: styles3.header }, /* @__PURE__ */ React6.createElement(View5, { style: { flex: 1, minWidth: 0 } }, /* @__PURE__ */ React6.createElement(Text2, { style: styles3.title, numberOfLines: 1 }, line.name), line.subtitle && /* @__PURE__ */ React6.createElement(Text2, { style: styles3.subtitle, numberOfLines: 1 }, line.subtitle), line.description && /* @__PURE__ */ React6.createElement(Text2, { style: styles3.description, numberOfLines: minimized ? 2 : 4 }, line.description)), /* @__PURE__ */ React6.createElement(View5, { style: styles3.headerActions }, /* @__PURE__ */ React6.createElement(AutoplayButton, null), /* @__PURE__ */ React6.createElement(Pressable2, { style: styles3.iconBtn, onPress: onToggleMinimize }, minimized ? /* @__PURE__ */ React6.createElement(ArrowUp2, { size: 14, color: "rgba(255,255,255,0.6)" }) : /* @__PURE__ */ React6.createElement(ArrowDown2, { size: 14, color: "rgba(255,255,255,0.6)" }))));
555
626
  }
556
627
  function AutoplayButton() {
557
- const { isPlaying, progress, controls } = useRetorBridge();
628
+ const { controls } = useRetorBridge();
629
+ const { isPlaying, progress } = useRetorProgress();
558
630
  const r = 12.5;
559
631
  const c = 2 * Math.PI * r;
560
632
  return /* @__PURE__ */ React6.createElement(Pressable2, { style: styles3.iconBtn, onPress: () => controls.toggleAutoplay() }, /* @__PURE__ */ React6.createElement(Svg, { width: 28, height: 28, style: StyleSheet5.absoluteFill }, /* @__PURE__ */ React6.createElement(
@@ -574,30 +646,34 @@ function AutoplayButton() {
574
646
  )), isPlaying ? /* @__PURE__ */ React6.createElement(Pause, { size: 11, color: "white", fill: "white" }) : /* @__PURE__ */ React6.createElement(Play, { size: 11, color: "white", fill: "white", style: { marginLeft: 1 } }));
575
647
  }
576
648
  function LineTagList({ children, listHeader }) {
577
- const { activeLine, closestTagId } = useRetorBridge();
649
+ const { activeLine, lineNotes, externalNotes } = useRetorBridge();
650
+ const { closestTagId } = useRetorProgress();
578
651
  const scrollRef = useRef3(null);
579
652
  const offsetsRef = useRef3(/* @__PURE__ */ new Map());
580
- const tags = useMemo4(
581
- () => (activeLine?.tags ?? []).filter((t) => t.name && t.name.trim().length > 0),
582
- [activeLine]
583
- );
653
+ const tags = useMemo4(() => {
654
+ if (!activeLine) return [];
655
+ const controls = (activeLine.tags ?? []).filter(
656
+ (t) => t.name && t.name.trim().length > 0
657
+ );
658
+ const allNotes = [...lineNotes, ...externalNotes].filter(
659
+ (n) => n.name && n.name.trim().length > 0
660
+ );
661
+ return mergeLineTagsByProgress(controls, allNotes);
662
+ }, [activeLine, lineNotes, externalNotes]);
584
663
  useEffect3(() => {
585
664
  offsetsRef.current = /* @__PURE__ */ new Map();
586
665
  }, [activeLine?._id]);
666
+ const lastScrolledIdRef = useRef3(null);
587
667
  useEffect3(() => {
588
- if (!closestTagId) return;
589
- let attempts = 0;
590
- const tick = () => {
668
+ if (!closestTagId || closestTagId === lastScrolledIdRef.current) return;
669
+ const t = setTimeout(() => {
591
670
  const y = offsetsRef.current.get(closestTagId);
592
671
  if (y != null) {
593
672
  scrollRef.current?.scrollTo?.({ y, animated: true });
594
- return;
673
+ lastScrolledIdRef.current = closestTagId;
595
674
  }
596
- attempts += 1;
597
- if (attempts < 10) raf = requestAnimationFrame(tick);
598
- };
599
- let raf = requestAnimationFrame(tick);
600
- return () => cancelAnimationFrame(raf);
675
+ }, 300);
676
+ return () => clearTimeout(t);
601
677
  }, [closestTagId]);
602
678
  if (!activeLine) return null;
603
679
  const handleItemLayout = (id, e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retor/react-native",
3
- "version": "0.3.6",
3
+ "version": "0.4.1",
4
4
  "description": "React Native SDK for embedding Retor 3D experiences",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",