@unhingged/vizu-react 0.1.20 → 0.1.22

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/README.md CHANGED
@@ -66,9 +66,43 @@ function YourPage() {
66
66
  }
67
67
  ```
68
68
 
69
+ ## Floating launcher pill
70
+
71
+ Not everyone knows the keyboard shortcut. Drop `<VizuLauncher />` anywhere inside the provider and visitors get a floating pill that turns Vizu on with one click (and kicks the sign-in popup in cloud mode, same as the shortcut):
72
+
73
+ ```tsx
74
+ import { VizuProvider, VizuLauncher } from '@vizu/react';
75
+
76
+ <VizuProvider options={{ namespace: 'my-site' }}>
77
+ <YourPage />
78
+ <VizuLauncher />
79
+ </VizuProvider>
80
+ ```
81
+
82
+ By default it sits bottom-right (Vizu's own toolbar takes bottom-center), shows the saved-comment count as a badge, and hides itself while Vizu is active — reappearing when Vizu is disabled. Everything is tweakable:
83
+
84
+ ```tsx
85
+ <VizuLauncher
86
+ label="Leave feedback" // pill text, default "Feedback"
87
+ position="bottom-left" // bottom-right | bottom-left | top-right | top-left
88
+ offset={32} // px from the viewport edges, default 20
89
+ showCount={false} // hide the comment-count badge
90
+ hideWhenActive={false} // keep it visible as an on/off toggle
91
+ accent="#7C5CFF" // dot + active border color
92
+ onToggle={(enabled) => track('vizu_toggled', { enabled })}
93
+ />
94
+ ```
95
+
96
+ Or replace the content entirely while keeping the pill shell and behavior:
97
+
98
+ ```tsx
99
+ <VizuLauncher>💬 Comments</VizuLauncher>
100
+ ```
101
+
69
102
  ## What you get
70
103
 
71
104
  - `<VizuProvider>` — mounts a single Vizu instance for the subtree (lazy, destroyed on unmount)
105
+ - `<VizuLauncher>` — floating pill that enables Vizu with a click (see above)
72
106
  - `useVizu()` — the instance itself for imperative calls (`vizu.enable()`, `vizu.clearAll()`, etc.)
73
107
  - `useComments()` — subscribes to all comment-list events, returns `VizuComment[]`
74
108
  - `useVizuUser()` — `[user, setUser]` tuple, re-renders on `user:changed`
@@ -88,7 +122,7 @@ Or skip storage entirely and own it in your effects — listen to `comment:added
88
122
 
89
123
  ## Next.js (App Router)
90
124
 
91
- `VizuProvider` is a client component (it uses refs + effects). Put it inside a layout that opts in:
125
+ `VizuProvider` is a client component (it uses refs + effects) and is StrictMode-safe — dev-mode's mount→cleanup→remount cycle recreates the instance instead of leaving a destroyed one in context. Put it inside a layout that opts in:
92
126
 
93
127
  ```tsx
94
128
  // app/(commentable)/layout.tsx
package/dist/index.cjs CHANGED
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  // src/index.tsx
22
22
  var src_exports = {};
23
23
  __export(src_exports, {
24
+ VizuLauncher: () => VizuLauncher,
24
25
  VizuProvider: () => VizuProvider,
25
26
  useComments: () => useComments,
26
27
  useVizu: () => useVizu,
@@ -29,26 +30,166 @@ __export(src_exports, {
29
30
  useVizuUser: () => useVizuUser
30
31
  });
31
32
  module.exports = __toCommonJS(src_exports);
32
- var import_react = require("react");
33
+ var import_react2 = require("react");
33
34
  var import_vizu_core = require("@unhingged/vizu-core");
35
+
36
+ // src/launcher.tsx
37
+ var import_react = require("react");
34
38
  var import_jsx_runtime = require("react/jsx-runtime");
35
- var VizuContext = (0, import_react.createContext)(null);
39
+ function useVizuEnabled(vizu) {
40
+ const subscribe = (0, import_react.useMemo)(
41
+ () => (cb) => {
42
+ const offs = [vizu.on("enabled", cb), vizu.on("disabled", cb)];
43
+ return () => offs.forEach((off) => off());
44
+ },
45
+ [vizu]
46
+ );
47
+ const getSnapshot = () => vizu.isEnabled();
48
+ return (0, import_react.useSyncExternalStore)(subscribe, getSnapshot, () => false);
49
+ }
50
+ function useCommentCount(vizu) {
51
+ const subscribe = (0, import_react.useMemo)(
52
+ () => (cb) => {
53
+ const offs = [
54
+ vizu.on("comment:added", cb),
55
+ vizu.on("comment:removed", cb),
56
+ vizu.on("comments:cleared", cb),
57
+ vizu.on("comments:set", cb),
58
+ vizu.on("comments:loaded", cb)
59
+ ];
60
+ return () => offs.forEach((off) => off());
61
+ },
62
+ [vizu]
63
+ );
64
+ const getSnapshot = () => vizu.getComments().length;
65
+ return (0, import_react.useSyncExternalStore)(subscribe, getSnapshot, () => 0);
66
+ }
67
+ var ACCENT_DEFAULT = "#FF6647";
68
+ function VizuLauncher({
69
+ label = "Feedback",
70
+ position = "bottom-right",
71
+ offset = 20,
72
+ showCount = true,
73
+ hideWhenActive = true,
74
+ accent = ACCENT_DEFAULT,
75
+ zIndex = 2147483646,
76
+ className,
77
+ style,
78
+ children,
79
+ onToggle
80
+ }) {
81
+ const vizu = useVizu();
82
+ const enabled = useVizuEnabled(vizu);
83
+ const count = useCommentCount(vizu);
84
+ const [hovered, setHovered] = (0, import_react.useState)(false);
85
+ const buttonRef = (0, import_react.useRef)(null);
86
+ if (enabled && hideWhenActive) return null;
87
+ const [vSide, hSide] = position.split("-");
88
+ const base = {
89
+ position: "fixed",
90
+ [vSide]: offset,
91
+ [hSide]: offset,
92
+ zIndex,
93
+ display: "inline-flex",
94
+ alignItems: "center",
95
+ gap: 8,
96
+ padding: "9px 16px",
97
+ borderRadius: 999,
98
+ border: `1px solid ${enabled ? accent : hovered ? "#3A3A3A" : "#2A2A2A"}`,
99
+ background: hovered ? "#1A1A1A" : "#0A0A0A",
100
+ color: "#fafafa",
101
+ font: '500 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
102
+ cursor: "pointer",
103
+ boxShadow: "0 8px 24px rgba(0,0,0,.4), inset 0 0 0 1px rgba(255,255,255,.04)",
104
+ userSelect: "none",
105
+ transition: "background 120ms, border-color 120ms, transform 120ms",
106
+ transform: hovered ? "translateY(-1px)" : "none"
107
+ };
108
+ const handleClick = () => {
109
+ vizu.toggle();
110
+ if (vizu.isEnabled()) vizu.requestAuth();
111
+ onToggle?.(vizu.isEnabled());
112
+ buttonRef.current?.blur();
113
+ };
114
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
115
+ "button",
116
+ {
117
+ ref: buttonRef,
118
+ type: "button",
119
+ "data-vizu-ignore": "",
120
+ className,
121
+ style: { ...base, ...style },
122
+ onClick: handleClick,
123
+ onMouseEnter: () => setHovered(true),
124
+ onMouseLeave: () => setHovered(false),
125
+ "aria-pressed": enabled,
126
+ title: enabled ? "Turn off commenting" : "Comment on this page",
127
+ children: children ?? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
128
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
129
+ "span",
130
+ {
131
+ "aria-hidden": "true",
132
+ style: {
133
+ width: 7,
134
+ height: 7,
135
+ borderRadius: "50%",
136
+ background: accent,
137
+ boxShadow: `0 0 8px ${accent}`,
138
+ flexShrink: 0
139
+ }
140
+ }
141
+ ),
142
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: label }),
143
+ showCount && count > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
144
+ "span",
145
+ {
146
+ style: {
147
+ minWidth: 18,
148
+ height: 18,
149
+ padding: "0 5px",
150
+ borderRadius: 9,
151
+ background: "#2A2A2A",
152
+ color: "#fafafa",
153
+ fontSize: 11,
154
+ fontWeight: 600,
155
+ display: "inline-flex",
156
+ alignItems: "center",
157
+ justifyContent: "center"
158
+ },
159
+ children: count
160
+ }
161
+ )
162
+ ] })
163
+ }
164
+ );
165
+ }
166
+
167
+ // src/index.tsx
168
+ var import_jsx_runtime2 = require("react/jsx-runtime");
169
+ var VizuContext = (0, import_react2.createContext)(null);
36
170
  function VizuProvider({ options, children }) {
37
- const ref = (0, import_react.useRef)(null);
171
+ const optionsRef = (0, import_react2.useRef)(options);
172
+ optionsRef.current = options;
173
+ const ref = (0, import_react2.useRef)(null);
38
174
  if (ref.current === null) {
39
175
  ref.current = new import_vizu_core.Vizu(options ?? {});
40
176
  }
41
- const vizu = ref.current;
42
- (0, import_react.useEffect)(() => {
177
+ const [vizu, setVizu] = (0, import_react2.useState)(ref.current);
178
+ (0, import_react2.useEffect)(() => {
179
+ if (ref.current === null) {
180
+ ref.current = new import_vizu_core.Vizu(optionsRef.current ?? {});
181
+ setVizu(ref.current);
182
+ }
183
+ const instance = ref.current;
43
184
  return () => {
44
- vizu.destroy();
185
+ instance.destroy();
45
186
  ref.current = null;
46
187
  };
47
188
  }, []);
48
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VizuContext.Provider, { value: vizu, children });
189
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(VizuContext.Provider, { value: vizu, children });
49
190
  }
50
191
  function useVizu() {
51
- const v = (0, import_react.useContext)(VizuContext);
192
+ const v = (0, import_react2.useContext)(VizuContext);
52
193
  if (!v) {
53
194
  throw new Error("useVizu must be called inside a <VizuProvider>.");
54
195
  }
@@ -56,16 +197,16 @@ function useVizu() {
56
197
  }
57
198
  function useVizuEvent(event, handler) {
58
199
  const vizu = useVizu();
59
- const handlerRef = (0, import_react.useRef)(handler);
200
+ const handlerRef = (0, import_react2.useRef)(handler);
60
201
  handlerRef.current = handler;
61
- (0, import_react.useEffect)(() => {
202
+ (0, import_react2.useEffect)(() => {
62
203
  const off = vizu.on(event, (payload) => handlerRef.current(payload));
63
204
  return off;
64
205
  }, [vizu, event]);
65
206
  }
66
207
  function useComments() {
67
208
  const vizu = useVizu();
68
- const subscribe = (0, import_react.useMemo)(
209
+ const subscribe = (0, import_react2.useMemo)(
69
210
  () => (cb) => {
70
211
  const offs = [
71
212
  vizu.on("comment:added", cb),
@@ -78,7 +219,7 @@ function useComments() {
78
219
  },
79
220
  [vizu]
80
221
  );
81
- const cacheRef = (0, import_react.useRef)(vizu.getComments());
222
+ const cacheRef = (0, import_react2.useRef)(vizu.getComments());
82
223
  const getSnapshot = () => {
83
224
  const next = vizu.getComments();
84
225
  const last = cacheRef.current;
@@ -88,23 +229,23 @@ function useComments() {
88
229
  cacheRef.current = next;
89
230
  return next;
90
231
  };
91
- return (0, import_react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
232
+ return (0, import_react2.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
92
233
  }
93
234
  function useVizuUser() {
94
235
  const vizu = useVizu();
95
- const subscribe = (0, import_react.useMemo)(
236
+ const subscribe = (0, import_react2.useMemo)(
96
237
  () => (cb) => vizu.on("user:changed", cb),
97
238
  [vizu]
98
239
  );
99
240
  const getSnapshot = () => vizu.getUser();
100
- const user = (0, import_react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
241
+ const user = (0, import_react2.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
101
242
  return [user, (u) => vizu.setUser(u)];
102
243
  }
103
244
  function useVizuAction(action) {
104
245
  const vizu = useVizu();
105
- const actionRef = (0, import_react.useRef)(action);
246
+ const actionRef = (0, import_react2.useRef)(action);
106
247
  actionRef.current = action;
107
- (0, import_react.useEffect)(() => {
248
+ (0, import_react2.useEffect)(() => {
108
249
  vizu.addAction({
109
250
  ...action,
110
251
  onClick: (ctx) => actionRef.current.onClick(ctx),
@@ -115,6 +256,7 @@ function useVizuAction(action) {
115
256
  }
116
257
  // Annotate the CommonJS export names for ESM import in node:
117
258
  0 && (module.exports = {
259
+ VizuLauncher,
118
260
  VizuProvider,
119
261
  useComments,
120
262
  useVizu,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.tsx"],"sourcesContent":["'use client';\n\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useSyncExternalStore,\n type ReactNode,\n} from 'react';\nimport {\n Vizu,\n type VizuOptions,\n type VizuComment,\n type VizuUser,\n type VizuAction,\n type VizuEventName,\n type VizuEventHandler,\n} from '@unhingged/vizu-core';\n\nexport type {\n Vizu,\n VizuOptions,\n VizuComment,\n VizuUser,\n VizuAction,\n VizuEventName,\n VizuEventHandler,\n};\n\nconst VizuContext = createContext<Vizu | null>(null);\n\nexport interface VizuProviderProps {\n options?: VizuOptions;\n children?: ReactNode;\n}\n\n/**\n * Mounts a single Vizu instance for the subtree. The instance is created lazily\n * once and persists across re-renders. Pass options once at construction;\n * imperative changes go through `useVizu()`.\n */\nexport function VizuProvider({ options, children }: VizuProviderProps) {\n // We need a stable instance across renders. Use a ref so re-renders don't construct new ones.\n const ref = useRef<Vizu | null>(null);\n if (ref.current === null) {\n ref.current = new Vizu(options ?? {});\n }\n const vizu = ref.current;\n\n useEffect(() => {\n return () => {\n vizu.destroy();\n ref.current = null;\n };\n // Intentionally only run on unmount. Recreating Vizu mid-tree would lose listeners + state.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n return <VizuContext.Provider value={vizu}>{children}</VizuContext.Provider>;\n}\n\n/** Returns the Vizu instance. Throws if used outside a <VizuProvider>. */\nexport function useVizu(): Vizu {\n const v = useContext(VizuContext);\n if (!v) {\n throw new Error('useVizu must be called inside a <VizuProvider>.');\n }\n return v;\n}\n\n/** Subscribe to a Vizu event for the component's lifetime. Fire-and-forget. */\nexport function useVizuEvent<E extends VizuEventName>(event: E, handler: VizuEventHandler<E>): void {\n const vizu = useVizu();\n // Stable handler ref — re-subscribe only when the event name changes.\n const handlerRef = useRef(handler);\n handlerRef.current = handler;\n useEffect(() => {\n const off = vizu.on(event, (payload) => handlerRef.current(payload));\n return off;\n }, [vizu, event]);\n}\n\n/** Returns the current comments array and re-renders when it changes. */\nexport function useComments(): VizuComment[] {\n const vizu = useVizu();\n // Stable subscribe + snapshot for useSyncExternalStore.\n const subscribe = useMemo(\n () => (cb: () => void) => {\n const offs = [\n vizu.on('comment:added', cb),\n vizu.on('comment:removed', cb),\n vizu.on('comments:cleared', cb),\n vizu.on('comments:set', cb),\n vizu.on('comments:loaded', cb),\n ];\n return () => offs.forEach((off) => off());\n },\n [vizu],\n );\n // getSnapshot must return a referentially stable result when the data hasn't changed.\n // We re-derive only when the subscribe fires; meanwhile memoize the last array.\n const cacheRef = useRef<VizuComment[]>(vizu.getComments());\n const getSnapshot = () => {\n const next = vizu.getComments();\n const last = cacheRef.current;\n if (last.length === next.length && last.every((c, i) => c === next[i] || (c.id === next[i].id && c.text === next[i].text))) {\n return last;\n }\n cacheRef.current = next;\n return next;\n };\n return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n\n/** Returns the current user and re-renders when it changes. Also exposes a setter. */\nexport function useVizuUser(): [VizuUser | null, (u: VizuUser | null) => void] {\n const vizu = useVizu();\n const subscribe = useMemo(\n () => (cb: () => void) => vizu.on('user:changed', cb),\n [vizu],\n );\n const getSnapshot = () => vizu.getUser();\n const user = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n return [user, (u) => vizu.setUser(u)];\n}\n\n/**\n * Register an action while the component is mounted. The action is removed\n * automatically on unmount. Re-renders DON'T re-register unless `action.id` changes.\n */\nexport function useVizuAction(action: VizuAction): void {\n const vizu = useVizu();\n const actionRef = useRef(action);\n actionRef.current = action;\n useEffect(() => {\n // Wrap so we always call the latest action.onClick / visibleWhen\n vizu.addAction({\n ...action,\n onClick: (ctx) => actionRef.current.onClick(ctx),\n visibleWhen: action.visibleWhen\n ? ({ commentsCount }) => actionRef.current.visibleWhen?.({ commentsCount }) ?? true\n : undefined,\n });\n return () => vizu.removeAction(action.id);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [vizu, action.id]);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.tsx","../src/launcher.tsx"],"sourcesContent":["'use client';\n\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from 'react';\nimport {\n Vizu,\n type VizuOptions,\n type VizuComment,\n type VizuUser,\n type VizuAction,\n type VizuEventName,\n type VizuEventHandler,\n} from '@unhingged/vizu-core';\n\nexport type {\n Vizu,\n VizuOptions,\n VizuComment,\n VizuUser,\n VizuAction,\n VizuEventName,\n VizuEventHandler,\n};\n\nexport { VizuLauncher } from './launcher';\nexport type { VizuLauncherProps, VizuLauncherPosition } from './launcher';\n\nconst VizuContext = createContext<Vizu | null>(null);\n\nexport interface VizuProviderProps {\n options?: VizuOptions;\n children?: ReactNode;\n}\n\n/**\n * Mounts a single Vizu instance for the subtree. The instance is created lazily\n * once and persists across re-renders. Pass options once at construction;\n * imperative changes go through `useVizu()`.\n */\nexport function VizuProvider({ options, children }: VizuProviderProps) {\n // Latest options, so a StrictMode recreation (below) doesn't construct\n // from a stale first-render snapshot.\n const optionsRef = useRef(options);\n optionsRef.current = options;\n\n // The instance must exist during the first render (children call useVizu\n // immediately), so construct lazily here rather than in the effect.\n const ref = useRef<Vizu | null>(null);\n if (ref.current === null) {\n ref.current = new Vizu(options ?? {});\n }\n // Context value lives in state so a recreated instance propagates to\n // consumers; re-renders don't construct new ones.\n const [vizu, setVizu] = useState<Vizu>(ref.current);\n\n useEffect(() => {\n // StrictMode's mount→cleanup→remount cycle destroys the render-time\n // instance in the first cleanup without re-running the render-phase\n // construction. Recreate here and push it through state so every\n // consumer re-subscribes to the live instance.\n if (ref.current === null) {\n ref.current = new Vizu(optionsRef.current ?? {});\n setVizu(ref.current);\n }\n const instance = ref.current;\n return () => {\n instance.destroy();\n ref.current = null;\n };\n }, []);\n\n return <VizuContext.Provider value={vizu}>{children}</VizuContext.Provider>;\n}\n\n/** Returns the Vizu instance. Throws if used outside a <VizuProvider>. */\nexport function useVizu(): Vizu {\n const v = useContext(VizuContext);\n if (!v) {\n throw new Error('useVizu must be called inside a <VizuProvider>.');\n }\n return v;\n}\n\n/** Subscribe to a Vizu event for the component's lifetime. Fire-and-forget. */\nexport function useVizuEvent<E extends VizuEventName>(event: E, handler: VizuEventHandler<E>): void {\n const vizu = useVizu();\n // Stable handler ref — re-subscribe only when the event name changes.\n const handlerRef = useRef(handler);\n handlerRef.current = handler;\n useEffect(() => {\n const off = vizu.on(event, (payload) => handlerRef.current(payload));\n return off;\n }, [vizu, event]);\n}\n\n/** Returns the current comments array and re-renders when it changes. */\nexport function useComments(): VizuComment[] {\n const vizu = useVizu();\n // Stable subscribe + snapshot for useSyncExternalStore.\n const subscribe = useMemo(\n () => (cb: () => void) => {\n const offs = [\n vizu.on('comment:added', cb),\n vizu.on('comment:removed', cb),\n vizu.on('comments:cleared', cb),\n vizu.on('comments:set', cb),\n vizu.on('comments:loaded', cb),\n ];\n return () => offs.forEach((off) => off());\n },\n [vizu],\n );\n // getSnapshot must return a referentially stable result when the data hasn't changed.\n // We re-derive only when the subscribe fires; meanwhile memoize the last array.\n const cacheRef = useRef<VizuComment[]>(vizu.getComments());\n const getSnapshot = () => {\n const next = vizu.getComments();\n const last = cacheRef.current;\n if (last.length === next.length && last.every((c, i) => c === next[i] || (c.id === next[i].id && c.text === next[i].text))) {\n return last;\n }\n cacheRef.current = next;\n return next;\n };\n return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n\n/** Returns the current user and re-renders when it changes. Also exposes a setter. */\nexport function useVizuUser(): [VizuUser | null, (u: VizuUser | null) => void] {\n const vizu = useVizu();\n const subscribe = useMemo(\n () => (cb: () => void) => vizu.on('user:changed', cb),\n [vizu],\n );\n const getSnapshot = () => vizu.getUser();\n const user = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n return [user, (u) => vizu.setUser(u)];\n}\n\n/**\n * Register an action while the component is mounted. The action is removed\n * automatically on unmount. Re-renders DON'T re-register unless `action.id` changes.\n */\nexport function useVizuAction(action: VizuAction): void {\n const vizu = useVizu();\n const actionRef = useRef(action);\n actionRef.current = action;\n useEffect(() => {\n // Wrap so we always call the latest action.onClick / visibleWhen\n vizu.addAction({\n ...action,\n onClick: (ctx) => actionRef.current.onClick(ctx),\n visibleWhen: action.visibleWhen\n ? ({ commentsCount }) => actionRef.current.visibleWhen?.({ commentsCount }) ?? true\n : undefined,\n });\n return () => vizu.removeAction(action.id);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [vizu, action.id]);\n}\n","'use client';\n\nimport {\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from 'react';\nimport type { Vizu } from '@unhingged/vizu-core';\nimport { useVizu } from './index';\n\nexport type VizuLauncherPosition =\n | 'bottom-right'\n | 'bottom-left'\n | 'top-right'\n | 'top-left';\n\nexport interface VizuLauncherProps {\n /** Text inside the pill. Default: \"Feedback\". */\n label?: string;\n /** Which corner the pill floats in. Default: 'bottom-right' (Vizu's own toolbar sits bottom-center). */\n position?: VizuLauncherPosition;\n /** Distance from the two nearest viewport edges, in px. Default: 20. */\n offset?: number;\n /** Show the saved-comment count as a badge. Default: true. */\n showCount?: boolean;\n /**\n * Hide the pill while Vizu is enabled. Default: true — Vizu's own toolbar\n * has a disable button, so a second toggle would be redundant. Pass false\n * to keep the pill visible as an on/off toggle instead.\n */\n hideWhenActive?: boolean;\n /** Accent for the dot + active border. Default: Vizu's accent (#FF6647). */\n accent?: string;\n /** Stacking context. Default: 2147483646 (one below Vizu's own overlay). */\n zIndex?: number;\n className?: string;\n /** Merged over the pill's base styles — positioning included, so `style={{ inset: ... }}` can relocate it entirely. */\n style?: CSSProperties;\n /** Replaces the default dot + label + count content. Styling of the pill shell still applies. */\n children?: ReactNode;\n /** Called after the pill toggles Vizu, with the new enabled state. */\n onToggle?: (enabled: boolean) => void;\n}\n\nfunction useVizuEnabled(vizu: Vizu): boolean {\n const subscribe = useMemo(\n () => (cb: () => void) => {\n const offs = [vizu.on('enabled', cb), vizu.on('disabled', cb)];\n return () => offs.forEach((off) => off());\n },\n [vizu],\n );\n const getSnapshot = () => vizu.isEnabled();\n return useSyncExternalStore(subscribe, getSnapshot, () => false);\n}\n\nfunction useCommentCount(vizu: Vizu): number {\n const subscribe = useMemo(\n () => (cb: () => void) => {\n const offs = [\n vizu.on('comment:added', cb),\n vizu.on('comment:removed', cb),\n vizu.on('comments:cleared', cb),\n vizu.on('comments:set', cb),\n vizu.on('comments:loaded', cb),\n ];\n return () => offs.forEach((off) => off());\n },\n [vizu],\n );\n const getSnapshot = () => vizu.getComments().length;\n return useSyncExternalStore(subscribe, getSnapshot, () => 0);\n}\n\nconst ACCENT_DEFAULT = '#FF6647';\n\n/**\n * A floating pill the host app renders anywhere inside <VizuProvider>.\n * One click enables Vizu — no keyboard shortcut needed. In cloud mode the\n * click also kicks the sign-in popup (same as the shortcut path), so a\n * signed-out reviewer lands straight in the auth flow.\n *\n * By default the pill hides itself while Vizu is active (Vizu's own toolbar\n * takes over) and reappears when Vizu is disabled.\n */\nexport function VizuLauncher({\n label = 'Feedback',\n position = 'bottom-right',\n offset = 20,\n showCount = true,\n hideWhenActive = true,\n accent = ACCENT_DEFAULT,\n zIndex = 2147483646,\n className,\n style,\n children,\n onToggle,\n}: VizuLauncherProps) {\n const vizu = useVizu();\n const enabled = useVizuEnabled(vizu);\n const count = useCommentCount(vizu);\n const [hovered, setHovered] = useState(false);\n const buttonRef = useRef<HTMLButtonElement>(null);\n\n if (enabled && hideWhenActive) return null;\n\n const [vSide, hSide] = position.split('-') as ['bottom' | 'top', 'left' | 'right'];\n\n const base: CSSProperties = {\n position: 'fixed',\n [vSide]: offset,\n [hSide]: offset,\n zIndex,\n display: 'inline-flex',\n alignItems: 'center',\n gap: 8,\n padding: '9px 16px',\n borderRadius: 999,\n border: `1px solid ${enabled ? accent : hovered ? '#3A3A3A' : '#2A2A2A'}`,\n background: hovered ? '#1A1A1A' : '#0A0A0A',\n color: '#fafafa',\n font: '500 13px/1 -apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, sans-serif',\n cursor: 'pointer',\n boxShadow: '0 8px 24px rgba(0,0,0,.4), inset 0 0 0 1px rgba(255,255,255,.04)',\n userSelect: 'none',\n transition: 'background 120ms, border-color 120ms, transform 120ms',\n transform: hovered ? 'translateY(-1px)' : 'none',\n };\n\n const handleClick = () => {\n vizu.toggle();\n // Enabling from a click is explicit user intent — kick cloud sign-in\n // now (no-op outside cloud mode / when signed in), mirroring the\n // keyboard shortcut's behavior.\n if (vizu.isEnabled()) vizu.requestAuth();\n onToggle?.(vizu.isEnabled());\n buttonRef.current?.blur();\n };\n\n return (\n <button\n ref={buttonRef}\n type=\"button\"\n // Keeps Vizu's highlighter from treating the launcher as a commentable\n // element when it stays visible while Vizu is active (hideWhenActive={false}).\n data-vizu-ignore=\"\"\n className={className}\n style={{ ...base, ...style }}\n onClick={handleClick}\n onMouseEnter={() => setHovered(true)}\n onMouseLeave={() => setHovered(false)}\n aria-pressed={enabled}\n title={enabled ? 'Turn off commenting' : 'Comment on this page'}\n >\n {children ?? (\n <>\n <span\n aria-hidden=\"true\"\n style={{\n width: 7,\n height: 7,\n borderRadius: '50%',\n background: accent,\n boxShadow: `0 0 8px ${accent}`,\n flexShrink: 0,\n }}\n />\n <span>{label}</span>\n {showCount && count > 0 && (\n <span\n style={{\n minWidth: 18,\n height: 18,\n padding: '0 5px',\n borderRadius: 9,\n background: '#2A2A2A',\n color: '#fafafa',\n fontSize: 11,\n fontWeight: 600,\n display: 'inline-flex',\n alignItems: 'center',\n justifyContent: 'center',\n }}\n >\n {count}\n </span>\n )}\n </>\n )}\n </button>\n );\n}\n"],"mappings":"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","names":["import_react","import_jsx_runtime"]}
package/dist/index.d.cts CHANGED
@@ -1,8 +1,47 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { ReactNode } from 'react';
2
+ import { CSSProperties, ReactNode } from 'react';
3
3
  import { VizuOptions, VizuComment, Vizu, VizuAction, VizuEventName, VizuEventHandler, VizuUser } from '@unhingged/vizu-core';
4
4
  export { Vizu, VizuAction, VizuComment, VizuEventHandler, VizuEventName, VizuOptions, VizuUser } from '@unhingged/vizu-core';
5
5
 
6
+ type VizuLauncherPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
7
+ interface VizuLauncherProps {
8
+ /** Text inside the pill. Default: "Feedback". */
9
+ label?: string;
10
+ /** Which corner the pill floats in. Default: 'bottom-right' (Vizu's own toolbar sits bottom-center). */
11
+ position?: VizuLauncherPosition;
12
+ /** Distance from the two nearest viewport edges, in px. Default: 20. */
13
+ offset?: number;
14
+ /** Show the saved-comment count as a badge. Default: true. */
15
+ showCount?: boolean;
16
+ /**
17
+ * Hide the pill while Vizu is enabled. Default: true — Vizu's own toolbar
18
+ * has a disable button, so a second toggle would be redundant. Pass false
19
+ * to keep the pill visible as an on/off toggle instead.
20
+ */
21
+ hideWhenActive?: boolean;
22
+ /** Accent for the dot + active border. Default: Vizu's accent (#FF6647). */
23
+ accent?: string;
24
+ /** Stacking context. Default: 2147483646 (one below Vizu's own overlay). */
25
+ zIndex?: number;
26
+ className?: string;
27
+ /** Merged over the pill's base styles — positioning included, so `style={{ inset: ... }}` can relocate it entirely. */
28
+ style?: CSSProperties;
29
+ /** Replaces the default dot + label + count content. Styling of the pill shell still applies. */
30
+ children?: ReactNode;
31
+ /** Called after the pill toggles Vizu, with the new enabled state. */
32
+ onToggle?: (enabled: boolean) => void;
33
+ }
34
+ /**
35
+ * A floating pill the host app renders anywhere inside <VizuProvider>.
36
+ * One click enables Vizu — no keyboard shortcut needed. In cloud mode the
37
+ * click also kicks the sign-in popup (same as the shortcut path), so a
38
+ * signed-out reviewer lands straight in the auth flow.
39
+ *
40
+ * By default the pill hides itself while Vizu is active (Vizu's own toolbar
41
+ * takes over) and reappears when Vizu is disabled.
42
+ */
43
+ declare function VizuLauncher({ label, position, offset, showCount, hideWhenActive, accent, zIndex, className, style, children, onToggle, }: VizuLauncherProps): react_jsx_runtime.JSX.Element | null;
44
+
6
45
  interface VizuProviderProps {
7
46
  options?: VizuOptions;
8
47
  children?: ReactNode;
@@ -27,4 +66,4 @@ declare function useVizuUser(): [VizuUser | null, (u: VizuUser | null) => void];
27
66
  */
28
67
  declare function useVizuAction(action: VizuAction): void;
29
68
 
30
- export { VizuProvider, type VizuProviderProps, useComments, useVizu, useVizuAction, useVizuEvent, useVizuUser };
69
+ export { VizuLauncher, type VizuLauncherPosition, type VizuLauncherProps, VizuProvider, type VizuProviderProps, useComments, useVizu, useVizuAction, useVizuEvent, useVizuUser };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,47 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import { ReactNode } from 'react';
2
+ import { CSSProperties, ReactNode } from 'react';
3
3
  import { VizuOptions, VizuComment, Vizu, VizuAction, VizuEventName, VizuEventHandler, VizuUser } from '@unhingged/vizu-core';
4
4
  export { Vizu, VizuAction, VizuComment, VizuEventHandler, VizuEventName, VizuOptions, VizuUser } from '@unhingged/vizu-core';
5
5
 
6
+ type VizuLauncherPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
7
+ interface VizuLauncherProps {
8
+ /** Text inside the pill. Default: "Feedback". */
9
+ label?: string;
10
+ /** Which corner the pill floats in. Default: 'bottom-right' (Vizu's own toolbar sits bottom-center). */
11
+ position?: VizuLauncherPosition;
12
+ /** Distance from the two nearest viewport edges, in px. Default: 20. */
13
+ offset?: number;
14
+ /** Show the saved-comment count as a badge. Default: true. */
15
+ showCount?: boolean;
16
+ /**
17
+ * Hide the pill while Vizu is enabled. Default: true — Vizu's own toolbar
18
+ * has a disable button, so a second toggle would be redundant. Pass false
19
+ * to keep the pill visible as an on/off toggle instead.
20
+ */
21
+ hideWhenActive?: boolean;
22
+ /** Accent for the dot + active border. Default: Vizu's accent (#FF6647). */
23
+ accent?: string;
24
+ /** Stacking context. Default: 2147483646 (one below Vizu's own overlay). */
25
+ zIndex?: number;
26
+ className?: string;
27
+ /** Merged over the pill's base styles — positioning included, so `style={{ inset: ... }}` can relocate it entirely. */
28
+ style?: CSSProperties;
29
+ /** Replaces the default dot + label + count content. Styling of the pill shell still applies. */
30
+ children?: ReactNode;
31
+ /** Called after the pill toggles Vizu, with the new enabled state. */
32
+ onToggle?: (enabled: boolean) => void;
33
+ }
34
+ /**
35
+ * A floating pill the host app renders anywhere inside <VizuProvider>.
36
+ * One click enables Vizu — no keyboard shortcut needed. In cloud mode the
37
+ * click also kicks the sign-in popup (same as the shortcut path), so a
38
+ * signed-out reviewer lands straight in the auth flow.
39
+ *
40
+ * By default the pill hides itself while Vizu is active (Vizu's own toolbar
41
+ * takes over) and reappears when Vizu is disabled.
42
+ */
43
+ declare function VizuLauncher({ label, position, offset, showCount, hideWhenActive, accent, zIndex, className, style, children, onToggle, }: VizuLauncherProps): react_jsx_runtime.JSX.Element | null;
44
+
6
45
  interface VizuProviderProps {
7
46
  options?: VizuOptions;
8
47
  children?: ReactNode;
@@ -27,4 +66,4 @@ declare function useVizuUser(): [VizuUser | null, (u: VizuUser | null) => void];
27
66
  */
28
67
  declare function useVizuAction(action: VizuAction): void;
29
68
 
30
- export { VizuProvider, type VizuProviderProps, useComments, useVizu, useVizuAction, useVizuEvent, useVizuUser };
69
+ export { VizuLauncher, type VizuLauncherPosition, type VizuLauncherProps, VizuProvider, type VizuProviderProps, useComments, useVizu, useVizuAction, useVizuEvent, useVizuUser };
package/dist/index.js CHANGED
@@ -5,28 +5,174 @@ import {
5
5
  createContext,
6
6
  useContext,
7
7
  useEffect,
8
- useMemo,
9
- useRef,
10
- useSyncExternalStore
8
+ useMemo as useMemo2,
9
+ useRef as useRef2,
10
+ useState as useState2,
11
+ useSyncExternalStore as useSyncExternalStore2
11
12
  } from "react";
12
13
  import {
13
14
  Vizu
14
15
  } from "@unhingged/vizu-core";
15
- import { jsx } from "react/jsx-runtime";
16
+
17
+ // src/launcher.tsx
18
+ import {
19
+ useMemo,
20
+ useRef,
21
+ useState,
22
+ useSyncExternalStore
23
+ } from "react";
24
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
25
+ function useVizuEnabled(vizu) {
26
+ const subscribe = useMemo(
27
+ () => (cb) => {
28
+ const offs = [vizu.on("enabled", cb), vizu.on("disabled", cb)];
29
+ return () => offs.forEach((off) => off());
30
+ },
31
+ [vizu]
32
+ );
33
+ const getSnapshot = () => vizu.isEnabled();
34
+ return useSyncExternalStore(subscribe, getSnapshot, () => false);
35
+ }
36
+ function useCommentCount(vizu) {
37
+ const subscribe = useMemo(
38
+ () => (cb) => {
39
+ const offs = [
40
+ vizu.on("comment:added", cb),
41
+ vizu.on("comment:removed", cb),
42
+ vizu.on("comments:cleared", cb),
43
+ vizu.on("comments:set", cb),
44
+ vizu.on("comments:loaded", cb)
45
+ ];
46
+ return () => offs.forEach((off) => off());
47
+ },
48
+ [vizu]
49
+ );
50
+ const getSnapshot = () => vizu.getComments().length;
51
+ return useSyncExternalStore(subscribe, getSnapshot, () => 0);
52
+ }
53
+ var ACCENT_DEFAULT = "#FF6647";
54
+ function VizuLauncher({
55
+ label = "Feedback",
56
+ position = "bottom-right",
57
+ offset = 20,
58
+ showCount = true,
59
+ hideWhenActive = true,
60
+ accent = ACCENT_DEFAULT,
61
+ zIndex = 2147483646,
62
+ className,
63
+ style,
64
+ children,
65
+ onToggle
66
+ }) {
67
+ const vizu = useVizu();
68
+ const enabled = useVizuEnabled(vizu);
69
+ const count = useCommentCount(vizu);
70
+ const [hovered, setHovered] = useState(false);
71
+ const buttonRef = useRef(null);
72
+ if (enabled && hideWhenActive) return null;
73
+ const [vSide, hSide] = position.split("-");
74
+ const base = {
75
+ position: "fixed",
76
+ [vSide]: offset,
77
+ [hSide]: offset,
78
+ zIndex,
79
+ display: "inline-flex",
80
+ alignItems: "center",
81
+ gap: 8,
82
+ padding: "9px 16px",
83
+ borderRadius: 999,
84
+ border: `1px solid ${enabled ? accent : hovered ? "#3A3A3A" : "#2A2A2A"}`,
85
+ background: hovered ? "#1A1A1A" : "#0A0A0A",
86
+ color: "#fafafa",
87
+ font: '500 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
88
+ cursor: "pointer",
89
+ boxShadow: "0 8px 24px rgba(0,0,0,.4), inset 0 0 0 1px rgba(255,255,255,.04)",
90
+ userSelect: "none",
91
+ transition: "background 120ms, border-color 120ms, transform 120ms",
92
+ transform: hovered ? "translateY(-1px)" : "none"
93
+ };
94
+ const handleClick = () => {
95
+ vizu.toggle();
96
+ if (vizu.isEnabled()) vizu.requestAuth();
97
+ onToggle?.(vizu.isEnabled());
98
+ buttonRef.current?.blur();
99
+ };
100
+ return /* @__PURE__ */ jsx(
101
+ "button",
102
+ {
103
+ ref: buttonRef,
104
+ type: "button",
105
+ "data-vizu-ignore": "",
106
+ className,
107
+ style: { ...base, ...style },
108
+ onClick: handleClick,
109
+ onMouseEnter: () => setHovered(true),
110
+ onMouseLeave: () => setHovered(false),
111
+ "aria-pressed": enabled,
112
+ title: enabled ? "Turn off commenting" : "Comment on this page",
113
+ children: children ?? /* @__PURE__ */ jsxs(Fragment, { children: [
114
+ /* @__PURE__ */ jsx(
115
+ "span",
116
+ {
117
+ "aria-hidden": "true",
118
+ style: {
119
+ width: 7,
120
+ height: 7,
121
+ borderRadius: "50%",
122
+ background: accent,
123
+ boxShadow: `0 0 8px ${accent}`,
124
+ flexShrink: 0
125
+ }
126
+ }
127
+ ),
128
+ /* @__PURE__ */ jsx("span", { children: label }),
129
+ showCount && count > 0 && /* @__PURE__ */ jsx(
130
+ "span",
131
+ {
132
+ style: {
133
+ minWidth: 18,
134
+ height: 18,
135
+ padding: "0 5px",
136
+ borderRadius: 9,
137
+ background: "#2A2A2A",
138
+ color: "#fafafa",
139
+ fontSize: 11,
140
+ fontWeight: 600,
141
+ display: "inline-flex",
142
+ alignItems: "center",
143
+ justifyContent: "center"
144
+ },
145
+ children: count
146
+ }
147
+ )
148
+ ] })
149
+ }
150
+ );
151
+ }
152
+
153
+ // src/index.tsx
154
+ import { jsx as jsx2 } from "react/jsx-runtime";
16
155
  var VizuContext = createContext(null);
17
156
  function VizuProvider({ options, children }) {
18
- const ref = useRef(null);
157
+ const optionsRef = useRef2(options);
158
+ optionsRef.current = options;
159
+ const ref = useRef2(null);
19
160
  if (ref.current === null) {
20
161
  ref.current = new Vizu(options ?? {});
21
162
  }
22
- const vizu = ref.current;
163
+ const [vizu, setVizu] = useState2(ref.current);
23
164
  useEffect(() => {
165
+ if (ref.current === null) {
166
+ ref.current = new Vizu(optionsRef.current ?? {});
167
+ setVizu(ref.current);
168
+ }
169
+ const instance = ref.current;
24
170
  return () => {
25
- vizu.destroy();
171
+ instance.destroy();
26
172
  ref.current = null;
27
173
  };
28
174
  }, []);
29
- return /* @__PURE__ */ jsx(VizuContext.Provider, { value: vizu, children });
175
+ return /* @__PURE__ */ jsx2(VizuContext.Provider, { value: vizu, children });
30
176
  }
31
177
  function useVizu() {
32
178
  const v = useContext(VizuContext);
@@ -37,7 +183,7 @@ function useVizu() {
37
183
  }
38
184
  function useVizuEvent(event, handler) {
39
185
  const vizu = useVizu();
40
- const handlerRef = useRef(handler);
186
+ const handlerRef = useRef2(handler);
41
187
  handlerRef.current = handler;
42
188
  useEffect(() => {
43
189
  const off = vizu.on(event, (payload) => handlerRef.current(payload));
@@ -46,7 +192,7 @@ function useVizuEvent(event, handler) {
46
192
  }
47
193
  function useComments() {
48
194
  const vizu = useVizu();
49
- const subscribe = useMemo(
195
+ const subscribe = useMemo2(
50
196
  () => (cb) => {
51
197
  const offs = [
52
198
  vizu.on("comment:added", cb),
@@ -59,7 +205,7 @@ function useComments() {
59
205
  },
60
206
  [vizu]
61
207
  );
62
- const cacheRef = useRef(vizu.getComments());
208
+ const cacheRef = useRef2(vizu.getComments());
63
209
  const getSnapshot = () => {
64
210
  const next = vizu.getComments();
65
211
  const last = cacheRef.current;
@@ -69,21 +215,21 @@ function useComments() {
69
215
  cacheRef.current = next;
70
216
  return next;
71
217
  };
72
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
218
+ return useSyncExternalStore2(subscribe, getSnapshot, getSnapshot);
73
219
  }
74
220
  function useVizuUser() {
75
221
  const vizu = useVizu();
76
- const subscribe = useMemo(
222
+ const subscribe = useMemo2(
77
223
  () => (cb) => vizu.on("user:changed", cb),
78
224
  [vizu]
79
225
  );
80
226
  const getSnapshot = () => vizu.getUser();
81
- const user = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
227
+ const user = useSyncExternalStore2(subscribe, getSnapshot, getSnapshot);
82
228
  return [user, (u) => vizu.setUser(u)];
83
229
  }
84
230
  function useVizuAction(action) {
85
231
  const vizu = useVizu();
86
- const actionRef = useRef(action);
232
+ const actionRef = useRef2(action);
87
233
  actionRef.current = action;
88
234
  useEffect(() => {
89
235
  vizu.addAction({
@@ -95,6 +241,7 @@ function useVizuAction(action) {
95
241
  }, [vizu, action.id]);
96
242
  }
97
243
  export {
244
+ VizuLauncher,
98
245
  VizuProvider,
99
246
  useComments,
100
247
  useVizu,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.tsx"],"sourcesContent":["'use client';\n\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useSyncExternalStore,\n type ReactNode,\n} from 'react';\nimport {\n Vizu,\n type VizuOptions,\n type VizuComment,\n type VizuUser,\n type VizuAction,\n type VizuEventName,\n type VizuEventHandler,\n} from '@unhingged/vizu-core';\n\nexport type {\n Vizu,\n VizuOptions,\n VizuComment,\n VizuUser,\n VizuAction,\n VizuEventName,\n VizuEventHandler,\n};\n\nconst VizuContext = createContext<Vizu | null>(null);\n\nexport interface VizuProviderProps {\n options?: VizuOptions;\n children?: ReactNode;\n}\n\n/**\n * Mounts a single Vizu instance for the subtree. The instance is created lazily\n * once and persists across re-renders. Pass options once at construction;\n * imperative changes go through `useVizu()`.\n */\nexport function VizuProvider({ options, children }: VizuProviderProps) {\n // We need a stable instance across renders. Use a ref so re-renders don't construct new ones.\n const ref = useRef<Vizu | null>(null);\n if (ref.current === null) {\n ref.current = new Vizu(options ?? {});\n }\n const vizu = ref.current;\n\n useEffect(() => {\n return () => {\n vizu.destroy();\n ref.current = null;\n };\n // Intentionally only run on unmount. Recreating Vizu mid-tree would lose listeners + state.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n return <VizuContext.Provider value={vizu}>{children}</VizuContext.Provider>;\n}\n\n/** Returns the Vizu instance. Throws if used outside a <VizuProvider>. */\nexport function useVizu(): Vizu {\n const v = useContext(VizuContext);\n if (!v) {\n throw new Error('useVizu must be called inside a <VizuProvider>.');\n }\n return v;\n}\n\n/** Subscribe to a Vizu event for the component's lifetime. Fire-and-forget. */\nexport function useVizuEvent<E extends VizuEventName>(event: E, handler: VizuEventHandler<E>): void {\n const vizu = useVizu();\n // Stable handler ref — re-subscribe only when the event name changes.\n const handlerRef = useRef(handler);\n handlerRef.current = handler;\n useEffect(() => {\n const off = vizu.on(event, (payload) => handlerRef.current(payload));\n return off;\n }, [vizu, event]);\n}\n\n/** Returns the current comments array and re-renders when it changes. */\nexport function useComments(): VizuComment[] {\n const vizu = useVizu();\n // Stable subscribe + snapshot for useSyncExternalStore.\n const subscribe = useMemo(\n () => (cb: () => void) => {\n const offs = [\n vizu.on('comment:added', cb),\n vizu.on('comment:removed', cb),\n vizu.on('comments:cleared', cb),\n vizu.on('comments:set', cb),\n vizu.on('comments:loaded', cb),\n ];\n return () => offs.forEach((off) => off());\n },\n [vizu],\n );\n // getSnapshot must return a referentially stable result when the data hasn't changed.\n // We re-derive only when the subscribe fires; meanwhile memoize the last array.\n const cacheRef = useRef<VizuComment[]>(vizu.getComments());\n const getSnapshot = () => {\n const next = vizu.getComments();\n const last = cacheRef.current;\n if (last.length === next.length && last.every((c, i) => c === next[i] || (c.id === next[i].id && c.text === next[i].text))) {\n return last;\n }\n cacheRef.current = next;\n return next;\n };\n return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n\n/** Returns the current user and re-renders when it changes. Also exposes a setter. */\nexport function useVizuUser(): [VizuUser | null, (u: VizuUser | null) => void] {\n const vizu = useVizu();\n const subscribe = useMemo(\n () => (cb: () => void) => vizu.on('user:changed', cb),\n [vizu],\n );\n const getSnapshot = () => vizu.getUser();\n const user = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n return [user, (u) => vizu.setUser(u)];\n}\n\n/**\n * Register an action while the component is mounted. The action is removed\n * automatically on unmount. Re-renders DON'T re-register unless `action.id` changes.\n */\nexport function useVizuAction(action: VizuAction): void {\n const vizu = useVizu();\n const actionRef = useRef(action);\n actionRef.current = action;\n useEffect(() => {\n // Wrap so we always call the latest action.onClick / visibleWhen\n vizu.addAction({\n ...action,\n onClick: (ctx) => actionRef.current.onClick(ctx),\n visibleWhen: action.visibleWhen\n ? ({ commentsCount }) => actionRef.current.visibleWhen?.({ commentsCount }) ?? true\n : undefined,\n });\n return () => vizu.removeAction(action.id);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [vizu, action.id]);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.tsx","../src/launcher.tsx"],"sourcesContent":["'use client';\n\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from 'react';\nimport {\n Vizu,\n type VizuOptions,\n type VizuComment,\n type VizuUser,\n type VizuAction,\n type VizuEventName,\n type VizuEventHandler,\n} from '@unhingged/vizu-core';\n\nexport type {\n Vizu,\n VizuOptions,\n VizuComment,\n VizuUser,\n VizuAction,\n VizuEventName,\n VizuEventHandler,\n};\n\nexport { VizuLauncher } from './launcher';\nexport type { VizuLauncherProps, VizuLauncherPosition } from './launcher';\n\nconst VizuContext = createContext<Vizu | null>(null);\n\nexport interface VizuProviderProps {\n options?: VizuOptions;\n children?: ReactNode;\n}\n\n/**\n * Mounts a single Vizu instance for the subtree. The instance is created lazily\n * once and persists across re-renders. Pass options once at construction;\n * imperative changes go through `useVizu()`.\n */\nexport function VizuProvider({ options, children }: VizuProviderProps) {\n // Latest options, so a StrictMode recreation (below) doesn't construct\n // from a stale first-render snapshot.\n const optionsRef = useRef(options);\n optionsRef.current = options;\n\n // The instance must exist during the first render (children call useVizu\n // immediately), so construct lazily here rather than in the effect.\n const ref = useRef<Vizu | null>(null);\n if (ref.current === null) {\n ref.current = new Vizu(options ?? {});\n }\n // Context value lives in state so a recreated instance propagates to\n // consumers; re-renders don't construct new ones.\n const [vizu, setVizu] = useState<Vizu>(ref.current);\n\n useEffect(() => {\n // StrictMode's mount→cleanup→remount cycle destroys the render-time\n // instance in the first cleanup without re-running the render-phase\n // construction. Recreate here and push it through state so every\n // consumer re-subscribes to the live instance.\n if (ref.current === null) {\n ref.current = new Vizu(optionsRef.current ?? {});\n setVizu(ref.current);\n }\n const instance = ref.current;\n return () => {\n instance.destroy();\n ref.current = null;\n };\n }, []);\n\n return <VizuContext.Provider value={vizu}>{children}</VizuContext.Provider>;\n}\n\n/** Returns the Vizu instance. Throws if used outside a <VizuProvider>. */\nexport function useVizu(): Vizu {\n const v = useContext(VizuContext);\n if (!v) {\n throw new Error('useVizu must be called inside a <VizuProvider>.');\n }\n return v;\n}\n\n/** Subscribe to a Vizu event for the component's lifetime. Fire-and-forget. */\nexport function useVizuEvent<E extends VizuEventName>(event: E, handler: VizuEventHandler<E>): void {\n const vizu = useVizu();\n // Stable handler ref — re-subscribe only when the event name changes.\n const handlerRef = useRef(handler);\n handlerRef.current = handler;\n useEffect(() => {\n const off = vizu.on(event, (payload) => handlerRef.current(payload));\n return off;\n }, [vizu, event]);\n}\n\n/** Returns the current comments array and re-renders when it changes. */\nexport function useComments(): VizuComment[] {\n const vizu = useVizu();\n // Stable subscribe + snapshot for useSyncExternalStore.\n const subscribe = useMemo(\n () => (cb: () => void) => {\n const offs = [\n vizu.on('comment:added', cb),\n vizu.on('comment:removed', cb),\n vizu.on('comments:cleared', cb),\n vizu.on('comments:set', cb),\n vizu.on('comments:loaded', cb),\n ];\n return () => offs.forEach((off) => off());\n },\n [vizu],\n );\n // getSnapshot must return a referentially stable result when the data hasn't changed.\n // We re-derive only when the subscribe fires; meanwhile memoize the last array.\n const cacheRef = useRef<VizuComment[]>(vizu.getComments());\n const getSnapshot = () => {\n const next = vizu.getComments();\n const last = cacheRef.current;\n if (last.length === next.length && last.every((c, i) => c === next[i] || (c.id === next[i].id && c.text === next[i].text))) {\n return last;\n }\n cacheRef.current = next;\n return next;\n };\n return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n\n/** Returns the current user and re-renders when it changes. Also exposes a setter. */\nexport function useVizuUser(): [VizuUser | null, (u: VizuUser | null) => void] {\n const vizu = useVizu();\n const subscribe = useMemo(\n () => (cb: () => void) => vizu.on('user:changed', cb),\n [vizu],\n );\n const getSnapshot = () => vizu.getUser();\n const user = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n return [user, (u) => vizu.setUser(u)];\n}\n\n/**\n * Register an action while the component is mounted. The action is removed\n * automatically on unmount. Re-renders DON'T re-register unless `action.id` changes.\n */\nexport function useVizuAction(action: VizuAction): void {\n const vizu = useVizu();\n const actionRef = useRef(action);\n actionRef.current = action;\n useEffect(() => {\n // Wrap so we always call the latest action.onClick / visibleWhen\n vizu.addAction({\n ...action,\n onClick: (ctx) => actionRef.current.onClick(ctx),\n visibleWhen: action.visibleWhen\n ? ({ commentsCount }) => actionRef.current.visibleWhen?.({ commentsCount }) ?? true\n : undefined,\n });\n return () => vizu.removeAction(action.id);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [vizu, action.id]);\n}\n","'use client';\n\nimport {\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type ReactNode,\n} from 'react';\nimport type { Vizu } from '@unhingged/vizu-core';\nimport { useVizu } from './index';\n\nexport type VizuLauncherPosition =\n | 'bottom-right'\n | 'bottom-left'\n | 'top-right'\n | 'top-left';\n\nexport interface VizuLauncherProps {\n /** Text inside the pill. Default: \"Feedback\". */\n label?: string;\n /** Which corner the pill floats in. Default: 'bottom-right' (Vizu's own toolbar sits bottom-center). */\n position?: VizuLauncherPosition;\n /** Distance from the two nearest viewport edges, in px. Default: 20. */\n offset?: number;\n /** Show the saved-comment count as a badge. Default: true. */\n showCount?: boolean;\n /**\n * Hide the pill while Vizu is enabled. Default: true — Vizu's own toolbar\n * has a disable button, so a second toggle would be redundant. Pass false\n * to keep the pill visible as an on/off toggle instead.\n */\n hideWhenActive?: boolean;\n /** Accent for the dot + active border. Default: Vizu's accent (#FF6647). */\n accent?: string;\n /** Stacking context. Default: 2147483646 (one below Vizu's own overlay). */\n zIndex?: number;\n className?: string;\n /** Merged over the pill's base styles — positioning included, so `style={{ inset: ... }}` can relocate it entirely. */\n style?: CSSProperties;\n /** Replaces the default dot + label + count content. Styling of the pill shell still applies. */\n children?: ReactNode;\n /** Called after the pill toggles Vizu, with the new enabled state. */\n onToggle?: (enabled: boolean) => void;\n}\n\nfunction useVizuEnabled(vizu: Vizu): boolean {\n const subscribe = useMemo(\n () => (cb: () => void) => {\n const offs = [vizu.on('enabled', cb), vizu.on('disabled', cb)];\n return () => offs.forEach((off) => off());\n },\n [vizu],\n );\n const getSnapshot = () => vizu.isEnabled();\n return useSyncExternalStore(subscribe, getSnapshot, () => false);\n}\n\nfunction useCommentCount(vizu: Vizu): number {\n const subscribe = useMemo(\n () => (cb: () => void) => {\n const offs = [\n vizu.on('comment:added', cb),\n vizu.on('comment:removed', cb),\n vizu.on('comments:cleared', cb),\n vizu.on('comments:set', cb),\n vizu.on('comments:loaded', cb),\n ];\n return () => offs.forEach((off) => off());\n },\n [vizu],\n );\n const getSnapshot = () => vizu.getComments().length;\n return useSyncExternalStore(subscribe, getSnapshot, () => 0);\n}\n\nconst ACCENT_DEFAULT = '#FF6647';\n\n/**\n * A floating pill the host app renders anywhere inside <VizuProvider>.\n * One click enables Vizu — no keyboard shortcut needed. In cloud mode the\n * click also kicks the sign-in popup (same as the shortcut path), so a\n * signed-out reviewer lands straight in the auth flow.\n *\n * By default the pill hides itself while Vizu is active (Vizu's own toolbar\n * takes over) and reappears when Vizu is disabled.\n */\nexport function VizuLauncher({\n label = 'Feedback',\n position = 'bottom-right',\n offset = 20,\n showCount = true,\n hideWhenActive = true,\n accent = ACCENT_DEFAULT,\n zIndex = 2147483646,\n className,\n style,\n children,\n onToggle,\n}: VizuLauncherProps) {\n const vizu = useVizu();\n const enabled = useVizuEnabled(vizu);\n const count = useCommentCount(vizu);\n const [hovered, setHovered] = useState(false);\n const buttonRef = useRef<HTMLButtonElement>(null);\n\n if (enabled && hideWhenActive) return null;\n\n const [vSide, hSide] = position.split('-') as ['bottom' | 'top', 'left' | 'right'];\n\n const base: CSSProperties = {\n position: 'fixed',\n [vSide]: offset,\n [hSide]: offset,\n zIndex,\n display: 'inline-flex',\n alignItems: 'center',\n gap: 8,\n padding: '9px 16px',\n borderRadius: 999,\n border: `1px solid ${enabled ? accent : hovered ? '#3A3A3A' : '#2A2A2A'}`,\n background: hovered ? '#1A1A1A' : '#0A0A0A',\n color: '#fafafa',\n font: '500 13px/1 -apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, sans-serif',\n cursor: 'pointer',\n boxShadow: '0 8px 24px rgba(0,0,0,.4), inset 0 0 0 1px rgba(255,255,255,.04)',\n userSelect: 'none',\n transition: 'background 120ms, border-color 120ms, transform 120ms',\n transform: hovered ? 'translateY(-1px)' : 'none',\n };\n\n const handleClick = () => {\n vizu.toggle();\n // Enabling from a click is explicit user intent — kick cloud sign-in\n // now (no-op outside cloud mode / when signed in), mirroring the\n // keyboard shortcut's behavior.\n if (vizu.isEnabled()) vizu.requestAuth();\n onToggle?.(vizu.isEnabled());\n buttonRef.current?.blur();\n };\n\n return (\n <button\n ref={buttonRef}\n type=\"button\"\n // Keeps Vizu's highlighter from treating the launcher as a commentable\n // element when it stays visible while Vizu is active (hideWhenActive={false}).\n data-vizu-ignore=\"\"\n className={className}\n style={{ ...base, ...style }}\n onClick={handleClick}\n onMouseEnter={() => setHovered(true)}\n onMouseLeave={() => setHovered(false)}\n aria-pressed={enabled}\n title={enabled ? 'Turn off commenting' : 'Comment on this page'}\n >\n {children ?? (\n <>\n <span\n aria-hidden=\"true\"\n style={{\n width: 7,\n height: 7,\n borderRadius: '50%',\n background: accent,\n boxShadow: `0 0 8px ${accent}`,\n flexShrink: 0,\n }}\n />\n <span>{label}</span>\n {showCount && count > 0 && (\n <span\n style={{\n minWidth: 18,\n height: 18,\n padding: '0 5px',\n borderRadius: 9,\n background: '#2A2A2A',\n color: '#fafafa',\n fontSize: 11,\n fontWeight: 600,\n display: 'inline-flex',\n alignItems: 'center',\n justifyContent: 'center',\n }}\n >\n {count}\n </span>\n )}\n </>\n )}\n </button>\n );\n}\n"],"mappings":"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","names":["useMemo","useRef","useState","useSyncExternalStore","jsx","useRef","useState","useMemo","useSyncExternalStore"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unhingged/vizu-react",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "React bindings for @unhingged/vizu-core — VizuProvider, useVizu, useComments, useVizuUser, useVizuEvent.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -23,17 +23,21 @@
23
23
  },
24
24
  "scripts": {
25
25
  "build": "tsup",
26
- "dev": "tsup --watch"
26
+ "dev": "tsup --watch",
27
+ "test": "bun test"
27
28
  },
28
29
  "peerDependencies": {
29
30
  "react": ">=18"
30
31
  },
31
32
  "dependencies": {
32
- "@unhingged/vizu-core": "workspace:*"
33
+ "@unhingged/vizu-core": "0.1.21"
33
34
  },
34
35
  "devDependencies": {
36
+ "@happy-dom/global-registrator": "^20.9.0",
35
37
  "@types/react": "^18.3.0",
38
+ "happy-dom": "^20.9.0",
36
39
  "react": "^18.3.1",
40
+ "react-dom": "^18.3.1",
37
41
  "tsup": "^8.3.5",
38
42
  "typescript": "^5.6.3"
39
43
  },