@livepreso/content-react 2.3.0 → 3.0.0

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/CHANGELOG.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@livepreso/content-react",
3
3
  "entries": [
4
+ {
5
+ "version": "3.0.0",
6
+ "tag": "@livepreso/content-react_v3.0.0",
7
+ "date": "Tue, 03 Nov 2026 22:02:25 GMT",
8
+ "comments": {
9
+ "minor": [
10
+ {
11
+ "comment": "Adds useActions for calling a deck's actions from a component, and useTargetKey for the key a component renders under."
12
+ },
13
+ {
14
+ "comment": "usePrepEditable supports a field edited on the everyone channel."
15
+ },
16
+ {
17
+ "comment": "A template placement is read-only while previewing."
18
+ },
19
+ {
20
+ "comment": "useActions registers its field with the host when the component mounts, and releases it when it unmounts."
21
+ }
22
+ ],
23
+ "major": [
24
+ {
25
+ "comment": "Editable reads and writes go through the deck interface. Requires a deck built with webpack-deck 4."
26
+ }
27
+ ],
28
+ "dependency": [
29
+ {
30
+ "comment": "Updating dependency \"@livepreso/webpack-deck\" to `4.0.0`"
31
+ },
32
+ {
33
+ "comment": "Updating dependency \"@livepreso/content-base\" from `~1.1.8` to `2.0.0`"
34
+ }
35
+ ]
36
+ }
37
+ },
38
+ {
39
+ "version": "2.3.1",
40
+ "tag": "@livepreso/content-react_v2.3.1",
41
+ "date": "Thu, 17 Sep 2026 01:30:15 GMT",
42
+ "comments": {
43
+ "patch": [
44
+ {
45
+ "comment": "Generate correct key when fetching user template values from the context."
46
+ }
47
+ ]
48
+ }
49
+ },
4
50
  {
5
51
  "version": "2.3.0",
6
52
  "tag": "@livepreso/content-react_v2.3.0",
package/CHANGELOG.md CHANGED
@@ -1,6 +1,27 @@
1
1
  # Change Log - @livepreso/content-react
2
2
 
3
- This log was last generated on Tue, 08 Sep 2026 00:30:21 GMT and should not be manually modified.
3
+ This log was last generated on Tue, 03 Nov 2026 22:02:25 GMT and should not be manually modified.
4
+
5
+ ## 3.0.0
6
+ Tue, 03 Nov 2026 22:02:25 GMT
7
+
8
+ ### Breaking changes
9
+
10
+ - Editable reads and writes go through the deck interface. Requires a deck built with webpack-deck 4.
11
+
12
+ ### Minor changes
13
+
14
+ - Adds useActions for calling a deck's actions from a component, and useTargetKey for the key a component renders under.
15
+ - usePrepEditable supports a field edited on the everyone channel.
16
+ - A template placement is read-only while previewing.
17
+ - useActions registers its field with the host when the component mounts, and releases it when it unmounts.
18
+
19
+ ## 2.3.1
20
+ Thu, 17 Sep 2026 01:30:15 GMT
21
+
22
+ ### Patches
23
+
24
+ - Generate correct key when fetching user template values from the context.
4
25
 
5
26
  ## 2.3.0
6
27
  Tue, 08 Sep 2026 00:30:21 GMT
@@ -0,0 +1,8 @@
1
+ // Only the test run uses this. A deck's build resolves its babel config from
2
+ // its own root, so nothing here reaches the bundle.
3
+ module.exports = {
4
+ presets: [
5
+ ["@babel/preset-env", { targets: { node: "current" } }],
6
+ ["@babel/preset-react", { runtime: "automatic" }],
7
+ ],
8
+ };
package/jest.config.js ADDED
@@ -0,0 +1,11 @@
1
+ // No jsdom and no renderer: the hooks under test hold no state of their own, so
2
+ // a spec calls them directly with the context hook they read stubbed out. A
3
+ // hook that later needs `useState` or `useMemo` will throw when called this
4
+ // way, which is the point at which one is worth setting up.
5
+ module.exports = {
6
+ testEnvironment: "node",
7
+ moduleNameMapper: {
8
+ // A deck's build resolves this to the project file it was built from.
9
+ "^@project$": "<rootDir>/test/project.json",
10
+ },
11
+ };
@@ -6,5 +6,9 @@ export function OverlayPortal({ children }) {
6
6
  if ((!entered && !exiting) || exited) {
7
7
  return;
8
8
  }
9
- return createPortal(children, window.Bridge.UI.overlay);
9
+ const overlay = window.Bridge.UI.overlay;
10
+ if (!overlay) {
11
+ return;
12
+ }
13
+ return createPortal(children, overlay);
10
14
  }
@@ -3,6 +3,7 @@ import { useTransitions } from "./use-transitions";
3
3
  import { useSlideContext } from "./use-slide-context.js";
4
4
  import { useSlide, useSlideModel } from "./slide.js";
5
5
  import { useModes } from "./use-modes.js";
6
+ import { getUserTemplateFieldKey } from "../utils.js";
6
7
 
7
8
  function useUserTemplateCMS(id) {
8
9
  const { slideID, presentationKey, isUserTemplate, isUserTemplateLibrary } =
@@ -11,7 +12,7 @@ function useUserTemplateCMS(id) {
11
12
  let contextKey;
12
13
 
13
14
  if (isUserTemplate) {
14
- contextKey = `_userTemplateCMSvals.${slideID}--${id}-${presentationKey}`;
15
+ contextKey = `_userTemplateCMSvals.${slideID}--${getUserTemplateFieldKey(id, slideID)}`;
15
16
  } else if (isUserTemplateLibrary) {
16
17
  contextKey = `_userTemplateCMSvals.${presentationKey}--${id}`;
17
18
  }
@@ -53,6 +54,24 @@ function useDefaultCMSValueFromPresomanager(id, { slidePath = null } = {}) {
53
54
  return cmsVals?.find?.((c) => c.key === key)?.value ?? null;
54
55
  }
55
56
 
57
+ function isShowing(presentationKey) {
58
+ return Bridge.presenter.getPresentation().current?.key === presentationKey;
59
+ }
60
+
61
+ /**
62
+ * Whether the app allows this field to be edited.
63
+ *
64
+ * True where nothing says otherwise, so a deck older than the check keeps
65
+ * working.
66
+ */
67
+ export function useCanEditCMSValKey(id) {
68
+ const { slidePath } = useSlide();
69
+
70
+ // Neighbouring slides are mounted too, so the key resolves against this slide
71
+ // rather than whichever one the presenter is showing.
72
+ return Bridge.CMSVals?.canEditKey?.(id, { slidePath }) ?? true;
73
+ }
74
+
56
75
  export function useCMSVal(
57
76
  id,
58
77
  { enabled = true, slidePath: slidePathOverride = null } = {},
@@ -63,10 +82,11 @@ export function useCMSVal(
63
82
  const {
64
83
  isUserTemplate,
65
84
  isUserTemplateLibrary,
85
+ presentationKey,
66
86
  slidePath: ownSlidePath,
67
87
  } = useSlide();
68
88
 
69
- // Defaults to the calling slide's path.
89
+ // A caller that passes no path gets the path of the slide it renders in.
70
90
  const slidePath = slidePathOverride ?? ownSlidePath;
71
91
  const [userTemplateValue, setUserTemplateValue] = useUserTemplateCMS(id);
72
92
  const defaultFromPM = useDefaultCMSValueFromPresomanager(id, { slidePath });
@@ -94,6 +114,12 @@ export function useCMSVal(
94
114
  return defaultFromPM;
95
115
  }
96
116
 
117
+ // Neighbouring slides are mounted too, and the presenter resolves a key
118
+ // against the slide that is showing.
119
+ if (!slidePath && !isShowing(presentationKey)) {
120
+ return undefined;
121
+ }
122
+
97
123
  return Bridge.CMSVals.get(id, { slidePath });
98
124
  }, [
99
125
  enabled,
@@ -101,11 +127,14 @@ export function useCMSVal(
101
127
  defaultFromPM,
102
128
  isUserTemplate,
103
129
  isUserTemplateLibrary,
130
+ userTemplateValue,
104
131
  isActiveSlide,
105
132
  isPresomanager,
133
+ // Read off the article, so it can arrive after the first render.
134
+ presentationKey,
106
135
  ]);
107
136
 
108
- const [value, setCMSValue] = useState(getCmsVal());
137
+ const [value, setCMSValue] = useState(getCmsVal);
109
138
 
110
139
  function updateCMSVal(value) {
111
140
  if (isUserTemplate) {
@@ -113,15 +142,28 @@ export function useCMSVal(
113
142
  } else {
114
143
  Bridge.CMSVals.set(id, value, { slidePath });
115
144
  }
116
- setCMSValue(value);
117
145
  }
118
146
 
147
+ useEffect(() => {
148
+ const stored = getCmsVal();
149
+
150
+ // undefined means the value cannot be read from here, so a slide part
151
+ // way out of view keeps what it had instead of blanking.
152
+ if (stored !== undefined) {
153
+ setCMSValue(stored);
154
+ }
155
+ }, [getCmsVal]);
156
+
119
157
  useEffect(() => {
120
158
  if (!enabled || !isActiveSlide) {
121
159
  return;
122
160
  }
123
161
 
124
162
  function onCMSValChanged(key, newValue) {
163
+ if (!slidePath && !isShowing(presentationKey)) {
164
+ return;
165
+ }
166
+
125
167
  const fullKey = Bridge.CMSVals.getFullKey(id, { slidePath });
126
168
 
127
169
  if (key !== fullKey) {
@@ -131,10 +173,10 @@ export function useCMSVal(
131
173
  setCMSValue(newValue);
132
174
  }
133
175
 
134
- Bridge.presenter.getPresentation().on("cmsValChanged", onCMSValChanged);
176
+ Bridge.CMSVals.on("changed", onCMSValChanged);
135
177
 
136
178
  return () => {
137
- Bridge.presenter.getPresentation().off("cmsValChanged", onCMSValChanged);
179
+ Bridge.CMSVals.off("changed", onCMSValChanged);
138
180
  };
139
181
  }, [enabled, id, getCmsVal]);
140
182
 
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { act, renderHook } from "@testing-library/react";
6
+
7
+ import { useCMSVal } from "./cmsvals.js";
8
+ import { useModes } from "./use-modes.js";
9
+ import { useSlide, useSlideModel } from "./slide.js";
10
+ import { useSlideContext } from "./use-slide-context.js";
11
+ import { useTransitions } from "./use-transitions";
12
+
13
+ jest.mock("./use-modes.js", () => ({ useModes: jest.fn() }));
14
+ jest.mock("./slide.js", () => ({
15
+ useSlide: jest.fn(),
16
+ useSlideModel: jest.fn(),
17
+ }));
18
+ jest.mock("./use-slide-context.js", () => ({ useSlideContext: jest.fn() }));
19
+ jest.mock("./use-transitions", () => ({ useTransitions: jest.fn() }));
20
+
21
+ function stub({ showing = "a-preso", stored = null } = {}) {
22
+ const listeners = new Set();
23
+ let current = showing ? { key: showing } : null;
24
+
25
+ const presentation = {
26
+ on: (name, listener) => listeners.add(listener),
27
+ off: (name, listener) => listeners.delete(listener),
28
+ get current() {
29
+ return current;
30
+ },
31
+ };
32
+
33
+ global.Bridge = {
34
+ CMSVals: {
35
+ get: jest.fn(() => stored),
36
+ set: jest.fn(),
37
+ getFullKey: (id) => `stored-${id}`,
38
+ on: (name, listener) => listeners.add(listener),
39
+ off: (name, listener) => listeners.delete(listener),
40
+ },
41
+ presenter: { getPresentation: () => presentation },
42
+ };
43
+
44
+ useTransitions.mockReturnValue({ entered: true, entering: false });
45
+ useModes.mockReturnValue({ isCdk: true });
46
+ useSlideModel.mockReturnValue(null);
47
+ useSlideContext.mockReturnValue([null, jest.fn()]);
48
+ useSlide.mockReturnValue({ presentationKey: showing });
49
+
50
+ return {
51
+ report: (key, value) => {
52
+ for (const listener of listeners) {
53
+ listener(key, value);
54
+ }
55
+ },
56
+ };
57
+ }
58
+
59
+ describe("useCMSVal", () => {
60
+ it("should read again once the slide it is on says which one it is", () => {
61
+ stub({ showing: "a-preso", stored: "what the store holds" });
62
+ useSlide.mockReturnValue({ presentationKey: null });
63
+
64
+ const { result, rerender } = renderHook(() => useCMSVal("a-note"));
65
+
66
+ expect(result.current[0]).toBeUndefined();
67
+
68
+ useSlide.mockReturnValue({ presentationKey: "a-preso" });
69
+ rerender();
70
+
71
+ expect(result.current[0]).toBe("what the store holds");
72
+ });
73
+
74
+ it("should show what the store reported rather than what was written", () => {
75
+ const { report } = stub({ stored: "what the store holds" });
76
+
77
+ const { result } = renderHook(() => useCMSVal("a-note"));
78
+
79
+ act(() => result.current[1]("typed"));
80
+
81
+ expect(result.current[0]).toBe("what the store holds");
82
+
83
+ act(() => report("stored-a-note", "what the store kept"));
84
+
85
+ expect(result.current[0]).toBe("what the store kept");
86
+ });
87
+ });
@@ -45,16 +45,18 @@ export function useCWEEditableInfo(id) {
45
45
  }
46
46
 
47
47
  useEffect(() => {
48
- if (isPresomanager) return;
48
+ if (isPresomanager) {
49
+ return;
50
+ }
49
51
 
50
52
  function onContextChange() {
51
53
  updateValue();
52
54
  }
53
55
 
54
- Bridge.presenter.getPresentation().on("change:context", onContextChange);
56
+ Bridge.Context.on("changed", onContextChange);
55
57
 
56
58
  return () => {
57
- Bridge.presenter.getPresentation().off("change:context", onContextChange);
59
+ Bridge.Context.off("changed", onContextChange);
58
60
  };
59
61
  }, [isPresomanager, value, getValue]);
60
62
 
@@ -9,6 +9,7 @@ export { useTransitions } from "./use-transitions.js";
9
9
  export { useSubslides } from "./use-subslides.js";
10
10
  export { useSlideDone } from "./use-slide-done.js";
11
11
  export { useEditableFieldKey } from "./use-editable-field-key.js";
12
+ export { useTargetKey } from "./use-target-key.js";
12
13
  export { useEditableInitialState } from "./use-editable-initial-state.js";
13
14
  export {
14
15
  usePrepEditable,
@@ -17,4 +18,5 @@ export {
17
18
  usePrepEditableImage,
18
19
  } from "./prep-editable.js";
19
20
  export { useCWEEditableInfo, useCWEEditableValue } from "./cwe-editable.js";
20
- export { useCMSVal } from "./cmsvals.js";
21
+ export { useCMSVal, useCanEditCMSValKey } from "./cmsvals.js";
22
+ export { useActions } from "./use-actions.js";
@@ -9,13 +9,14 @@ import { useEditableFieldKey } from "./use-editable-field-key";
9
9
  * of the context to allow the user template magic to work, and this is the
10
10
  * function to use to make that seamless.
11
11
  */
12
- export function usePrepEditable(editableKey) {
13
- const { isPresomanager } = useModes();
12
+ export function usePrepEditable(editableKey, { isGlobal = false } = {}) {
13
+ const { isPresomanager, isReadOnly: isPageReadOnly } = useModes();
14
14
  const { slideID, isUserTemplateLibrary } = useSlide();
15
15
 
16
16
  const key = useEditableFieldKey(editableKey, { isPrep: true });
17
17
 
18
- const prepKey = `${slideID}--${key}`;
18
+ // Not owned by a slide, so every slide using this key sees one value.
19
+ const prepKey = isGlobal ? key : `${slideID}--${key}`;
19
20
  const keyPath = `_editableContent.${prepKey}`;
20
21
 
21
22
  const [obj, setObj] = useSlideContext(
@@ -25,7 +26,7 @@ export function usePrepEditable(editableKey) {
25
26
  value: null,
26
27
  },
27
28
  {
28
- enabled: !isPresomanager || isUserTemplateLibrary,
29
+ enabled: !isPageReadOnly && (!isPresomanager || isUserTemplateLibrary),
29
30
  },
30
31
  );
31
32
 
@@ -70,7 +71,7 @@ export function usePrepEditableValue(id, defaultValue = null) {
70
71
  * Returns the URL of a prep editable dynamic image on the current slide.
71
72
  */
72
73
  export function usePrepEditableImage(editableKey) {
73
- const { isPresomanager } = useModes();
74
+ const { isPresomanager, isReadOnly: isPageReadOnly } = useModes();
74
75
  const { slideID, isUserTemplateLibrary } = useSlide();
75
76
 
76
77
  const key = useEditableFieldKey(editableKey, { isPrep: true });
@@ -82,7 +83,7 @@ export function usePrepEditableImage(editableKey) {
82
83
  keyPath,
83
84
  { image: null },
84
85
  {
85
- enabled: !isPresomanager || isUserTemplateLibrary,
86
+ enabled: !isPageReadOnly && (!isPresomanager || isUserTemplateLibrary),
86
87
  },
87
88
  );
88
89
 
@@ -0,0 +1,78 @@
1
+ import { useModes } from "./use-modes.js";
2
+ import { usePrepEditable } from "./prep-editable.js";
3
+ import { useSlide } from "./slide.js";
4
+ import { useSlideContext } from "./use-slide-context.js";
5
+
6
+ jest.mock("./use-modes.js", () => ({ useModes: jest.fn() }));
7
+ jest.mock("./slide.js", () => ({ useSlide: jest.fn() }));
8
+ jest.mock("./use-slide-context.js", () => ({ useSlideContext: jest.fn() }));
9
+
10
+ function stub() {
11
+ useModes.mockReturnValue({ isPresomanager: false });
12
+ useSlide.mockReturnValue({
13
+ slideID: "opening",
14
+ isPMTemplate: false,
15
+ isUserTemplate: false,
16
+ isUserTemplateLibrary: false,
17
+ });
18
+ useSlideContext.mockReturnValue([{ value: "Stored" }, jest.fn()]);
19
+ }
20
+
21
+ describe("usePrepEditable", () => {
22
+ beforeEach(() => {
23
+ jest.clearAllMocks();
24
+ stub();
25
+ });
26
+
27
+ it("should keep a value against the slide it was written on", () => {
28
+ usePrepEditable("note");
29
+
30
+ expect(useSlideContext).toHaveBeenCalledWith(
31
+ "_editableContent.opening--note",
32
+ expect.anything(),
33
+ expect.anything(),
34
+ );
35
+ });
36
+
37
+ it("should offer a prep editable on a user template being edited", () => {
38
+ useModes.mockReturnValue({ isPresomanager: true, isReadOnly: false });
39
+ useSlide.mockReturnValue({
40
+ slideID: "opening",
41
+ isUserTemplateLibrary: true,
42
+ });
43
+
44
+ usePrepEditable("note");
45
+
46
+ expect(useSlideContext).toHaveBeenCalledWith(
47
+ expect.anything(),
48
+ expect.anything(),
49
+ { enabled: true },
50
+ );
51
+ });
52
+
53
+ it("should offer none on a template preview that stores nothing", () => {
54
+ useModes.mockReturnValue({ isPresomanager: true, isReadOnly: true });
55
+ useSlide.mockReturnValue({
56
+ slideID: "opening",
57
+ isUserTemplateLibrary: true,
58
+ });
59
+
60
+ usePrepEditable("note");
61
+
62
+ expect(useSlideContext).toHaveBeenCalledWith(
63
+ expect.anything(),
64
+ expect.anything(),
65
+ { enabled: false },
66
+ );
67
+ });
68
+
69
+ it("should keep a global value under its bare key", () => {
70
+ usePrepEditable("note", { isGlobal: true });
71
+
72
+ expect(useSlideContext).toHaveBeenCalledWith(
73
+ "_editableContent.note",
74
+ expect.anything(),
75
+ expect.anything(),
76
+ );
77
+ });
78
+ });
@@ -6,8 +6,8 @@ export function useSlide() {
6
6
  const {
7
7
  slideID,
8
8
  slideKey,
9
- presentationKey,
10
9
  slidePath,
10
+ presentationKey,
11
11
  isSlideReady,
12
12
  doneRegister,
13
13
  isCurrentSlide,
@@ -22,8 +22,8 @@ export function useSlide() {
22
22
  return {
23
23
  slideID,
24
24
  slideKey,
25
- presentationKey,
26
25
  slidePath,
26
+ presentationKey,
27
27
  isSlideReady,
28
28
  doneRegister,
29
29
  isCurrentSlide,
@@ -0,0 +1,112 @@
1
+ import { useEffect, useMemo, useReducer } from "react";
2
+ import { bindActions, provideRendered } from "@livepreso/webpack-deck";
3
+
4
+ import { useSlide } from "./slide.js";
5
+ import { useTargetKey } from "./use-target-key.js";
6
+
7
+ /**
8
+ * The queries and mutations this editable declares, bound to
9
+ * wherever it is stored.
10
+ *
11
+ * ```
12
+ * const { queries, mutations } = useActions(editable, props);
13
+ *
14
+ * value={queries.get()} onChange={(html) => mutations.set({ html })}
15
+ * await queries.getAsync() await mutations.set({ html })
16
+ * ```
17
+ *
18
+ * A person editing the content reaches the mutation an assistant calls, which
19
+ * is why a component is never handed a plain setter.
20
+ */
21
+ export function useActions(
22
+ source,
23
+ args,
24
+ { defaultValue = null, field, rendered, onValidationError } = {},
25
+ ) {
26
+ const { slidePath } = useSlide();
27
+ const targetKey = useTargetKey();
28
+
29
+ const bound = useMemo(
30
+ () =>
31
+ bindActions(source, args, {
32
+ targetKey,
33
+ slidePath,
34
+ defaultValue,
35
+ field,
36
+ isRegistered: false,
37
+ }),
38
+ // The declaration and the args are the same objects every render, and the
39
+ // binding reads its stores on every call, so only where it stores can
40
+ // change under it.
41
+ [targetKey, slidePath],
42
+ );
43
+
44
+ useRendered(bound.key, rendered);
45
+
46
+ // Lists a field the build missed while it is on screen, so it can be edited.
47
+ useEffect(() => bound.register(), [bound]);
48
+
49
+ const [, render] = useReducer((count) => count + 1, 0);
50
+
51
+ useEffect(() => {
52
+ bound.on("changed", render);
53
+
54
+ return () => bound.off("changed", render);
55
+ }, [bound]);
56
+
57
+ useEffect(() => {
58
+ if (!onValidationError) {
59
+ return undefined;
60
+ }
61
+
62
+ bound.on("validation-error", onValidationError);
63
+
64
+ return () => bound.off("validation-error", onValidationError);
65
+ }, [bound, onValidationError]);
66
+
67
+ return {
68
+ channel: bound.channel,
69
+ errors: bound.errors,
70
+ queries: bound.queries,
71
+ mutations: bound.mutations,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Holds what a field has while it is on screen, for the entries that name it.
77
+ *
78
+ * The nodes are read on every render because a ref is filled after the render
79
+ * that created it, so reading during one would hold null.
80
+ */
81
+ function useRendered(fieldKey, refs) {
82
+ const names = Object.keys(refs ?? {});
83
+
84
+ useEffect(() => {
85
+ if (names.length === 0) {
86
+ return undefined;
87
+ }
88
+
89
+ return provideRendered(
90
+ fieldKey,
91
+ Object.fromEntries(
92
+ names.map((name) => [name, readRenderedValue(refs[name])]),
93
+ ),
94
+ );
95
+ });
96
+ }
97
+
98
+ /**
99
+ * A ref gives its element. A flag such as `always: true` passes through, so an entry can
100
+ * require the slide on screen.
101
+ */
102
+ function readRenderedValue(value) {
103
+ if (isRef(value)) {
104
+ return value.current;
105
+ }
106
+
107
+ return value;
108
+ }
109
+
110
+ function isRef(value) {
111
+ return typeof value === "object" && value !== null && "current" in value;
112
+ }