@gogitcms/editor 0.41.0 → 0.42.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/app/src/App.tsx CHANGED
@@ -33,17 +33,19 @@ import {
33
33
  pluginNavKey,
34
34
  usePluginRegistry,
35
35
  type PluginDocument,
36
+ type PluginFieldFocus,
36
37
  type PluginTabSpec,
37
38
  } from "@gogitcms/plugin-sdk";
38
39
  import { pluginRegistry } from "./plugins";
39
40
  import {
40
41
  DraftBus,
42
+ FocusBus,
41
43
  PluginDocumentActions,
42
44
  PluginTabBody,
43
45
  pluginTabId,
44
46
  type PluginTabRef,
45
47
  } from "./previewHost";
46
- import { useDocCollab, type DocCollab } from "@gogitcms/realtime";
48
+ import { colorForId, useDocCollab, type DocCollab } from "@gogitcms/realtime";
47
49
  import { client, setAuthToken, getAuthToken } from "./apollo";
48
50
  import { config, analyticsConfig } from "./config";
49
51
  import { AnalyticsEvent, AnalyticsProvider, useAnalytics, useIdentify } from "@gogitcms/analytics";
@@ -1388,6 +1390,8 @@ function CmsView({
1388
1390
  // One per browser instance, never recreated — a new bus would silently drop
1389
1391
  // every existing subscription.
1390
1392
  const draftBus = useRef(new DraftBus()).current;
1393
+ // Who is focused on which field of each open document, for the same tabs.
1394
+ const focusBus = useRef(new FocusBus()).current;
1391
1395
 
1392
1396
  // Current user identity for collaborative presence (email local-part as label).
1393
1397
  const { data: meData } = useQuery(ME);
@@ -1411,6 +1415,50 @@ function CmsView({
1411
1415
  return next;
1412
1416
  });
1413
1417
  }, []);
1418
+ // Field focus, merged per document from two sources: the field this editor's
1419
+ // author is in (ContentBrowser's onFieldFocus) and the fields collaborators
1420
+ // are in (the document's awareness, the same presence the form shows as
1421
+ // coloured outlines). Refs rather than state: both move on every focus
1422
+ // change and awareness tick, and nothing here re-renders on them — the bus
1423
+ // fans the merged set straight out to the preview panes.
1424
+ const localFocusRef = useRef<Record<string, string | null>>({});
1425
+ const loadedRef = useRef(loaded);
1426
+ loadedRef.current = loaded;
1427
+ const collabRef = useRef(collabById);
1428
+ collabRef.current = collabById;
1429
+ const collabUserRef = useRef(collabUser);
1430
+ collabUserRef.current = collabUser;
1431
+ const publishFocus = useCallback(
1432
+ (id: string) => {
1433
+ const path = loadedRef.current[id]?.path ?? "";
1434
+ const me = collabUserRef.current;
1435
+ const focus: PluginFieldFocus[] = [];
1436
+ const local = localFocusRef.current[id];
1437
+ // The author's own colour is the one collab derives for them, so the
1438
+ // ring on the page matches the outline a collaborator sees in the form.
1439
+ if (local && me) {
1440
+ focus.push({ documentId: id, path, field: local, user: { id: me.id, name: me.name, color: colorForId(me.id) }, self: true });
1441
+ }
1442
+ const collab = collabRef.current[id];
1443
+ if (collab) {
1444
+ for (const p of collab.participants()) {
1445
+ if (!p.focus) continue;
1446
+ focus.push({ documentId: id, path, field: p.focus, user: { id: p.id, name: p.name, color: p.color }, self: false });
1447
+ }
1448
+ }
1449
+ focusBus.publish(id, focus);
1450
+ },
1451
+ [focusBus],
1452
+ );
1453
+ useEffect(() => {
1454
+ const offs = Object.entries(collabById).map(([id, collab]) => {
1455
+ publishFocus(id);
1456
+ return collab.subscribeAwareness(() => publishFocus(id));
1457
+ });
1458
+ return () => {
1459
+ for (const off of offs) off();
1460
+ };
1461
+ }, [collabById, publishFocus]);
1414
1462
  // The URL-selected document always materializes as a tab.
1415
1463
  useEffect(() => {
1416
1464
  // The changes surface has no editor tabs — its selection is a diff, not an
@@ -2034,6 +2082,7 @@ function CmsView({
2034
2082
  tabId={t.id}
2035
2083
  tab={p}
2036
2084
  bus={draftBus}
2085
+ focusBus={focusBus}
2037
2086
  initialDocument={anchor ? toPluginDoc(anchor, anchorCollection) : null}
2038
2087
  workspaceId={params.workspaceId}
2039
2088
  repositoryId={repo.id}
@@ -2369,6 +2418,16 @@ function CmsView({
2369
2418
  });
2370
2419
  }
2371
2420
  }
2421
+ // Which field the author is in, for the preview panes to ring on the
2422
+ // page. Nothing on the changes surface has a field to be in.
2423
+ onFieldFocus={
2424
+ changesMode
2425
+ ? undefined
2426
+ : (id, path) => {
2427
+ localFocusRef.current[id] = path;
2428
+ publishFocus(id);
2429
+ }
2430
+ }
2372
2431
  renderField={renderField}
2373
2432
  media={media}
2374
2433
  forms={forms}
@@ -13,6 +13,7 @@ import {
13
13
  usePluginRegistry,
14
14
  type PluginDocument,
15
15
  type PluginDocumentActionContext,
16
+ type PluginFieldFocus,
16
17
  type PluginRegistry,
17
18
  type PluginTabSpec,
18
19
  } from "@gogitcms/plugin-sdk";
@@ -93,6 +94,77 @@ export class DraftBus {
93
94
  }
94
95
  }
95
96
 
97
+ // --- the focus bus ----------------------------------------------------------
98
+
99
+ type FocusListener = (focus: PluginFieldFocus[]) => void;
100
+
101
+ /**
102
+ * Who is focused on which field, per document, fanned out to the plugin tabs
103
+ * open on it.
104
+ *
105
+ * Same shape as the draft bus and for the same reason. The value is the
106
+ * whole set for the document — the author at this editor plus every
107
+ * collaborator in its session — so a subscriber paints exactly what it is
108
+ * handed and never has to reconcile deltas. Retained per document so a tab
109
+ * opened while a field is already focused rings it on arrival.
110
+ */
111
+ export class FocusBus {
112
+ private listeners = new Map<string, Set<FocusListener>>();
113
+ private last = new Map<string, PluginFieldFocus[]>();
114
+
115
+ publish(documentId: string, focus: PluginFieldFocus[]): void {
116
+ const previous = this.last.get(documentId);
117
+ if (previous && sameFocus(previous, focus)) return;
118
+ this.last.set(documentId, focus);
119
+ const set = this.listeners.get(documentId);
120
+ if (!set) return;
121
+ for (const l of set) {
122
+ try {
123
+ l(focus);
124
+ } catch (err) {
125
+ console.error(`[plugins] a focus subscriber threw: ${String(err)}`);
126
+ }
127
+ }
128
+ }
129
+
130
+ subscribe(documentId: string, listener: FocusListener): () => void {
131
+ let set = this.listeners.get(documentId);
132
+ if (!set) {
133
+ set = new Set();
134
+ this.listeners.set(documentId, set);
135
+ }
136
+ set.add(listener);
137
+ listener(this.last.get(documentId) ?? []);
138
+ return () => {
139
+ set!.delete(listener);
140
+ if (set!.size === 0) this.listeners.delete(documentId);
141
+ };
142
+ }
143
+
144
+ forget(documentId: string): void {
145
+ this.last.delete(documentId);
146
+ this.listeners.delete(documentId);
147
+ }
148
+ }
149
+
150
+ // Awareness ticks for reasons other than focus (a cursor, a heartbeat), and
151
+ // each one would otherwise re-post the same set to every preview frame.
152
+ function sameFocus(a: PluginFieldFocus[], b: PluginFieldFocus[]): boolean {
153
+ if (a.length !== b.length) return false;
154
+ return a.every((x, i) => {
155
+ const y = b[i];
156
+ return (
157
+ x.documentId === y.documentId &&
158
+ x.path === y.path &&
159
+ x.field === y.field &&
160
+ x.self === y.self &&
161
+ x.user.id === y.user.id &&
162
+ x.user.name === y.user.name &&
163
+ x.user.color === y.user.color
164
+ );
165
+ });
166
+ }
167
+
96
168
  // --- toolbar buttons --------------------------------------------------------
97
169
 
98
170
  export type DocumentActionsProps = {
@@ -181,6 +253,8 @@ export type PluginTabBodyProps = {
181
253
  tabId: string;
182
254
  tab: PluginTabRef;
183
255
  bus: DraftBus;
256
+ /** Who is focused where, for the tab's document. Optional for older hosts. */
257
+ focusBus?: FocusBus;
184
258
  /** The document as last loaded, for a tab opened before any edit. */
185
259
  initialDocument: PluginDocument | null;
186
260
  workspaceId: string;
@@ -195,13 +269,18 @@ export type PluginTabBodyProps = {
195
269
 
196
270
  /** Renders a plugin route inside a column, with its tab context attached. */
197
271
  export function PluginTabBody(props: PluginTabBodyProps) {
198
- const { bus, tab, initialDocument } = props;
272
+ const { bus, focusBus, tab, initialDocument } = props;
199
273
 
200
274
  const onDraft = useCallback(
201
275
  (cb: (doc: PluginDocument) => void) =>
202
276
  tab.documentId ? bus.subscribe(tab.documentId, cb) : () => {},
203
277
  [bus, tab.documentId],
204
278
  );
279
+ const onFocus = useCallback(
280
+ (cb: (focus: PluginFieldFocus[]) => void) =>
281
+ focusBus && tab.documentId ? focusBus.subscribe(tab.documentId, cb) : () => {},
282
+ [focusBus, tab.documentId],
283
+ );
205
284
 
206
285
  // The tab context is memoized on its inputs: it is a prop of the plugin's
207
286
  // component, and a fresh object each render would re-run every effect the
@@ -211,10 +290,11 @@ export function PluginTabBody(props: PluginTabBodyProps) {
211
290
  id: props.tabId,
212
291
  document: initialDocument,
213
292
  onDraft,
293
+ onFocus,
214
294
  openDocument: props.openDocument,
215
295
  close: props.close,
216
296
  }),
217
- [props.tabId, initialDocument, onDraft, props.openDocument, props.close],
297
+ [props.tabId, initialDocument, onDraft, onFocus, props.openDocument, props.close],
218
298
  );
219
299
 
220
300
  return (
@@ -0,0 +1,109 @@
1
+ // Which field the author is in, reported as a dotted path — what a preview
2
+ // pane uses to ring the matching part of the page. Read off the DOM's focus
3
+ // events and the `data-cms-field` attribute every control's wrapper carries,
4
+ // so it works for every kind of control and for a lone author with no live
5
+ // session.
6
+ import React from "react";
7
+ import { render, screen, fireEvent, act } from "@testing-library/react";
8
+ import { ThemeProvider } from "../ThemeProvider";
9
+ import { ContentBrowser, type CmsEntry, type CmsNavSection, type EntryField } from "../components/ContentBrowser";
10
+
11
+ jest.mock("../ThemeProvider", () => {
12
+ const actual = jest.requireActual("../ThemeProvider");
13
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
14
+ });
15
+
16
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
17
+
18
+ const sections: CmsNavSection[] = [
19
+ { title: "Content", items: [{ key: "pages", label: "Pages", icon: "newspaper" }] },
20
+ ];
21
+
22
+ const fields: EntryField[] = [
23
+ { name: "title", label: "Title", type: "string", value: "Home", required: true },
24
+ {
25
+ name: "blocks",
26
+ label: "Blocks",
27
+ type: "array",
28
+ component: "mixedList",
29
+ value: [{ _variant: "hero", heading: "Hi", stats: [{ label: "a", value: "1" }] }],
30
+ variants: [
31
+ {
32
+ name: "hero",
33
+ fields: [
34
+ { name: "heading", label: "Heading", type: "string", value: null },
35
+ {
36
+ name: "stats",
37
+ label: "Stats",
38
+ type: "array",
39
+ of: "object",
40
+ value: null,
41
+ fields: [
42
+ { name: "label", label: "Label", type: "string", value: null },
43
+ { name: "value", label: "Value", type: "string", value: null },
44
+ ],
45
+ },
46
+ ],
47
+ },
48
+ ],
49
+ },
50
+ ];
51
+
52
+ const entry: CmsEntry = { id: "doc-1", path: "content/pages/home.json", title: "Home", body: "", fields };
53
+
54
+ function renderBrowser(onFieldFocus = jest.fn()) {
55
+ render(
56
+ wrap(
57
+ <ContentBrowser
58
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
59
+ sections={sections}
60
+ activeNavKey="pages"
61
+ onSelectNav={() => {}}
62
+ entries={[entry]}
63
+ userInitials="ED"
64
+ onSaveEntry={jest.fn()}
65
+ onFieldFocus={onFieldFocus}
66
+ />,
67
+ ),
68
+ );
69
+ return onFieldFocus;
70
+ }
71
+
72
+ // The blur report is deferred a tick so a move between fields never passes
73
+ // through null; flush it.
74
+ const settle = () => act(() => new Promise((r) => setTimeout(r, 5)));
75
+
76
+ describe("onFieldFocus", () => {
77
+ it("reports a top-level field by name and null when it blurs", async () => {
78
+ const onFieldFocus = renderBrowser();
79
+ const title = screen.getByDisplayValue("Home");
80
+ fireEvent.focusIn(title);
81
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "title");
82
+ fireEvent.focusOut(title);
83
+ await settle();
84
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", null);
85
+ });
86
+
87
+ it("composes the path of a field inside a mixed-list item and a nested list", async () => {
88
+ const onFieldFocus = renderBrowser();
89
+ const heading = screen.getByDisplayValue("Hi");
90
+ fireEvent.focusIn(heading);
91
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "blocks.0.heading");
92
+
93
+ const statLabel = screen.getByDisplayValue("a");
94
+ // Straight from one field to the next: no null in between.
95
+ fireEvent.focusOut(heading);
96
+ fireEvent.focusIn(statLabel);
97
+ await settle();
98
+ expect(onFieldFocus).not.toHaveBeenCalledWith("doc-1", null);
99
+ expect(onFieldFocus).toHaveBeenLastCalledWith("doc-1", "blocks.0.stats.0.label");
100
+ });
101
+
102
+ it("annotates every control's wrapper with its path", () => {
103
+ renderBrowser();
104
+ const paths = Array.from(document.querySelectorAll("[data-cms-field]")).map((el) => el.getAttribute("data-cms-field"));
105
+ expect(paths).toEqual(
106
+ expect.arrayContaining(["title", "blocks", "blocks.0", "blocks.0.heading", "blocks.0.stats", "blocks.0.stats.0", "blocks.0.stats.0.label", "blocks.0.stats.0.value"]),
107
+ );
108
+ });
109
+ });
@@ -336,6 +336,15 @@ export type ContentBrowserProps = {
336
336
  // already separated from the frontmatter.
337
337
  onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
338
338
 
339
+ // Which field of an open document has keyboard focus, as the author moves
340
+ // between controls: the field's dotted path (`title`, `seo.description`,
341
+ // `blocks.0.heading`), or null when nothing in that document is focused.
342
+ // Web only — it reads the DOM's focus events. This is what lets a preview
343
+ // ring the part of the page the author is editing (§4.4 of the preview
344
+ // design), and it is separate from live collaboration: a lone author gets
345
+ // it too.
346
+ onFieldFocus?: (entryId: string, path: string | null) => void;
347
+
339
348
  // Optional per-field renderer. When it returns a node for a field, that node
340
349
  // replaces the built-in control (used to inject the markdown body editor).
341
350
  renderField?: RenderField;
@@ -782,8 +791,40 @@ type FieldControlProps = {
782
791
  // (see FieldSlotArgs.resetNonce); every built-in control is controlled and
783
792
  // re-renders from `value` on its own.
784
793
  resetNonce?: number;
794
+ // The field's full dotted path, when the caller knows better than
795
+ // "parent path + field name" — a list item, whose control is named after
796
+ // the list but sits at `list.<index>`.
797
+ fieldPath?: string;
785
798
  };
786
799
 
800
+ // The dotted path of the field being rendered, for the controls under it:
801
+ // a group's children append their names to it, a list's items append their
802
+ // index. It exists so every control can carry its own path as a DOM
803
+ // attribute (`data-cms-field`) without any of them threading a prop through
804
+ // — the focus reporting in EntryDetail reads that attribute off whichever
805
+ // element the keyboard lands in.
806
+ //
807
+ // Deliberately separate from the collab `path` prop: that one is threaded
808
+ // only where a CRDT binding exists (top-level fields and drilled-in groups),
809
+ // whereas this is present for every field including list items, which the
810
+ // collab layer treats as one register.
811
+ const FieldPathContext = React.createContext<string>("");
812
+
813
+ function FieldControl(props: FieldControlProps) {
814
+ const prefix = React.useContext(FieldPathContext);
815
+ const fullPath = props.fieldPath ?? (prefix ? `${prefix}.${props.field.name}` : props.field.name);
816
+ return (
817
+ <FieldPathContext.Provider value={fullPath}>
818
+ <View
819
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
820
+ dataSet={{ cmsField: fullPath }}
821
+ >
822
+ <FieldControlInner {...props} />
823
+ </View>
824
+ </FieldPathContext.Provider>
825
+ );
826
+ }
827
+
787
828
  // The scalar type an array's `of` maps to when rendering item controls.
788
829
  function ofToType(of?: string): string {
789
830
  switch (of) {
@@ -794,7 +835,7 @@ function ofToType(of?: string): string {
794
835
  }
795
836
  }
796
837
 
797
- function FieldControl(props: FieldControlProps) {
838
+ function FieldControlInner(props: FieldControlProps) {
798
839
  const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal, resetNonce } = props;
799
840
  const t = useTheme();
800
841
  const label = hideLabel ? "" : field.label || field.name;
@@ -1631,11 +1672,14 @@ function ListControl({
1631
1672
  ...(field.media ? { component: "media", media: field.media, storeAs: field.storeAs } : {}),
1632
1673
  };
1633
1674
 
1675
+ // The list's own path, from the FieldControl wrapping this control; an item
1676
+ // sits at `<list>.<index>`.
1677
+ const listPath = React.useContext(FieldPathContext);
1634
1678
  return (
1635
1679
  <View style={{ gap: t.space(2) }}>
1636
1680
  {items.map((it, i) => (
1637
1681
  <ItemFrame key={i} index={i} count={items.length} readOnly={readOnly || !canRemove} onRemove={() => removeItem(i)} onMove={(d) => moveItem(i, d)}>
1638
- <FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel />
1682
+ <FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel fieldPath={listPath ? `${listPath}.${i}` : String(i)} />
1639
1683
  </ItemFrame>
1640
1684
  ))}
1641
1685
  {!readOnly && canAdd ? (
@@ -1675,15 +1719,25 @@ function MixedListControl({
1675
1719
  onChange(next);
1676
1720
  };
1677
1721
  const addItem = () => onChange([...items, { [key]: variants[0]?.name ?? "" }]);
1722
+ const listPath = React.useContext(FieldPathContext);
1678
1723
 
1679
1724
  return (
1680
1725
  <View style={{ gap: t.space(2) }}>
1681
1726
  {items.map((it, i) => {
1682
1727
  const variantName = String(it?.[key] ?? "");
1683
1728
  const variant = variants.find((v) => v.name === variantName);
1729
+ const itemPath = listPath ? `${listPath}.${i}` : String(i);
1684
1730
  return (
1685
1731
  <ItemFrame key={i} index={i} count={items.length} readOnly={readOnly || !canRemove} onRemove={() => removeItem(i)} onMove={(d) => moveItem(i, d)}>
1686
- <View style={{ gap: t.space(2) }}>
1732
+ {/* The item's fields compose under `<list>.<index>`; the item
1733
+ itself carries that path so focusing its variant picker
1734
+ reports the item rather than the whole list. */}
1735
+ <FieldPathContext.Provider value={itemPath}>
1736
+ <View
1737
+ style={{ gap: t.space(2) }}
1738
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
1739
+ dataSet={{ cmsField: itemPath }}
1740
+ >
1687
1741
  <SelectControl
1688
1742
  value={variantName}
1689
1743
  options={variants.map((v) => v.name)}
@@ -1701,6 +1755,7 @@ function MixedListControl({
1701
1755
  />
1702
1756
  ) : null}
1703
1757
  </View>
1758
+ </FieldPathContext.Provider>
1704
1759
  </ItemFrame>
1705
1760
  );
1706
1761
  })}
@@ -1745,7 +1800,13 @@ function BodyField({
1745
1800
  readOnly,
1746
1801
  });
1747
1802
  return (
1748
- <View style={{ gap: t.space(2), flex: 1 }}>
1803
+ <View
1804
+ style={{ gap: t.space(2), flex: 1 }}
1805
+ // The schema-less body is the document's one field; a preview rings
1806
+ // it under the same name the schema form would give it.
1807
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
1808
+ dataSet={{ cmsField: "body" }}
1809
+ >
1749
1810
  <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
1750
1811
  <Text variant="label" color="tertiary">Body</Text>
1751
1812
  <Text variant="monoSm" color="tertiary">Markdown</Text>
@@ -2597,6 +2658,7 @@ function EntryDetail({
2597
2658
  entry,
2598
2659
  documentActions,
2599
2660
  onEntryDraft,
2661
+ onFieldFocus,
2600
2662
  renderField,
2601
2663
  onSaveEntry,
2602
2664
  readOnly = false,
@@ -2619,6 +2681,8 @@ function EntryDetail({
2619
2681
  documentActions?: (entry: CmsEntry) => React.ReactNode;
2620
2682
  // Debounced in-flight draft (see ContentBrowserProps.onEntryDraft).
2621
2683
  onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
2684
+ // The focused field's path, or null (see ContentBrowserProps.onFieldFocus).
2685
+ onFieldFocus?: (entryId: string, path: string | null) => void;
2622
2686
  renderField?: RenderField;
2623
2687
  onSaveEntry?: SaveEntry;
2624
2688
  readOnly?: boolean;
@@ -2972,8 +3036,54 @@ function EntryDetail({
2972
3036
  // The field list + nested value object for the active frame.
2973
3037
  const frame = resolveFrame(editFields, values, groupPath);
2974
3038
 
3039
+ // Which field has the keyboard. One pair of DOM focus listeners on the
3040
+ // column rather than an onFocus on every control: the controls are many
3041
+ // (inputs, selects, the ProseMirror body, plugin fields) and every one of
3042
+ // them renders inside the FieldControl wrapper that carries the path as
3043
+ // `data-cms-field` — so the element the focus landed in is enough.
3044
+ //
3045
+ // focusout fires before the next focusin, so a blur is reported a tick
3046
+ // late and cancelled if focus went straight to another field: moving
3047
+ // between two controls reads as one change, not a flicker through null.
3048
+ const focusRoot = useRef<View>(null);
3049
+ const fieldFocus = useRef(onFieldFocus);
3050
+ fieldFocus.current = onFieldFocus;
3051
+ const entryId = entry.id;
3052
+ useEffect(() => {
3053
+ if (Platform.OS !== "web") return;
3054
+ const node = focusRoot.current as unknown as HTMLElement | null;
3055
+ if (!node || typeof node.addEventListener !== "function") return;
3056
+ let pending: ReturnType<typeof setTimeout> | null = null;
3057
+ let last: string | null = null;
3058
+ const report = (path: string | null) => {
3059
+ if (path === last) return;
3060
+ last = path;
3061
+ fieldFocus.current?.(entryId, path);
3062
+ };
3063
+ const onFocusIn = (ev: Event) => {
3064
+ if (pending) { clearTimeout(pending); pending = null; }
3065
+ const target = ev.target as Element | null;
3066
+ const el = target && typeof target.closest === "function" ? target.closest("[data-cms-field]") : null;
3067
+ report(el?.getAttribute("data-cms-field") || null);
3068
+ };
3069
+ const onFocusOut = () => {
3070
+ if (pending) clearTimeout(pending);
3071
+ pending = setTimeout(() => { pending = null; report(null); }, 0);
3072
+ };
3073
+ node.addEventListener("focusin", onFocusIn);
3074
+ node.addEventListener("focusout", onFocusOut);
3075
+ return () => {
3076
+ node.removeEventListener("focusin", onFocusIn);
3077
+ node.removeEventListener("focusout", onFocusOut);
3078
+ if (pending) clearTimeout(pending);
3079
+ // The column is going away with the field still focused: nothing in
3080
+ // this document has focus any more.
3081
+ if (last !== null) fieldFocus.current?.(entryId, null);
3082
+ };
3083
+ }, [entryId]);
3084
+
2975
3085
  return (
2976
- <View style={{ flex: 1 }}>
3086
+ <View style={{ flex: 1 }} ref={focusRoot}>
2977
3087
  {/* breadcrumb + actions. position/zIndex lift this row (and the "..." menu
2978
3088
  dropdown it hosts) above the content pane so the menu receives clicks. */}
2979
3089
  <View
@@ -3129,6 +3239,8 @@ function EntryDetail({
3129
3239
  <Text variant="monoSm" color="tertiary">{frame.labels.join(" / ")}</Text>
3130
3240
  </Pressable>
3131
3241
  ) : null}
3242
+ {/* Drilled into a group, every field's path starts with the group's. */}
3243
+ <FieldPathContext.Provider value={groupPath.join(".")}>
3132
3244
  {frame.fields.map((f) => (
3133
3245
  <FieldControl
3134
3246
  key={f.name}
@@ -3147,6 +3259,7 @@ function EntryDetail({
3147
3259
  resetNonce={resetNonces[f.name]}
3148
3260
  />
3149
3261
  ))}
3262
+ </FieldPathContext.Provider>
3150
3263
  </ScrollView>
3151
3264
  </DocumentPathProvider>
3152
3265
  ) : (
@@ -4133,6 +4246,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4133
4246
  documentActions={props.documentActions}
4134
4247
  history={props.history}
4135
4248
  onEntryDraft={props.onEntryDraft}
4249
+ onFieldFocus={props.onFieldFocus}
4136
4250
  renderField={renderField}
4137
4251
  onSaveEntry={props.onSaveEntry}
4138
4252
  readOnly={col.readOnly}
@@ -5009,6 +5123,7 @@ function MobileBrowser(props: ContentBrowserProps) {
5009
5123
  documentActions={props.documentActions}
5010
5124
  history={props.history}
5011
5125
  onEntryDraft={props.onEntryDraft}
5126
+ onFieldFocus={props.onFieldFocus}
5012
5127
  renderField={renderField}
5013
5128
  onSaveEntry={props.onSaveEntry}
5014
5129
  readOnly={readOnly}
@@ -5,6 +5,7 @@ export type {
5
5
  PluginDocumentAction,
6
6
  PluginDocumentActionContext,
7
7
  PluginFieldComponent,
8
+ PluginFieldFocus,
8
9
  PluginFieldProps,
9
10
  PluginFieldType,
10
11
  PluginLoadEntry,
@@ -32,6 +32,24 @@ export type PluginDocument = {
32
32
  bodyField?: string;
33
33
  };
34
34
 
35
+ /**
36
+ * One field somebody is focused on in the editor, and who.
37
+ *
38
+ * Structurally the preview protocol's `PreviewFocus`, so a preview plugin can
39
+ * hand the list straight to the page. `self` marks the user driving this
40
+ * editor; the rest are collaborators from the document's live session, with
41
+ * the presence colour the editor already shows beside their field.
42
+ */
43
+ export type PluginFieldFocus = {
44
+ documentId: string;
45
+ /** Repo-relative path of the document. */
46
+ path: string;
47
+ /** Dotted field path: `title`, `seo.description`, `blocks.0.heading`. */
48
+ field: string;
49
+ user: { id: string; name: string; color: string };
50
+ self: boolean;
51
+ };
52
+
35
53
  /**
36
54
  * Extra props a route gets when it is rendered as a *tab* beside the editor
37
55
  * rather than as a full screen at its URL.
@@ -51,6 +69,14 @@ export type PluginTabContext = {
51
69
  * unsubscribe function.
52
70
  */
53
71
  onDraft: (cb: (doc: PluginDocument) => void) => () => void;
72
+ /**
73
+ * Subscribe to who is focused on which of that document's fields — the
74
+ * user at this editor and every collaborator in its live session. Called
75
+ * with the full set on every change, and once on subscribe with the current
76
+ * one. Optional: an older host has no notion of it, and a plugin treats its
77
+ * absence as "nobody is focused".
78
+ */
79
+ onFocus?: (cb: (focus: PluginFieldFocus[]) => void) => () => void;
54
80
  /** Open a document in the editor — the inverse direction, for click-to-edit. */
55
81
  openDocument: (id: string, field?: string) => void;
56
82
  /** Close this tab. */
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@gogitcms/editor",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@gogitcms/editor",
9
- "version": "0.41.0",
9
+ "version": "0.42.0",
10
10
  "dependencies": {
11
11
  "@apollo/client": "^3.11.8",
12
12
  "@handlewithcare/react-prosemirror": "^3.2.7",
@@ -47,11 +47,11 @@
47
47
  "node": ">=20"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@gogitcms/gitcms-local-darwin-arm64": "0.41.0",
51
- "@gogitcms/gitcms-local-darwin-x64": "0.41.0",
52
- "@gogitcms/gitcms-local-linux-arm64": "0.41.0",
53
- "@gogitcms/gitcms-local-linux-x64": "0.41.0",
54
- "@gogitcms/gitcms-local-win32-x64": "0.41.0"
50
+ "@gogitcms/gitcms-local-darwin-arm64": "0.42.0",
51
+ "@gogitcms/gitcms-local-darwin-x64": "0.42.0",
52
+ "@gogitcms/gitcms-local-linux-arm64": "0.42.0",
53
+ "@gogitcms/gitcms-local-linux-x64": "0.42.0",
54
+ "@gogitcms/gitcms-local-win32-x64": "0.42.0"
55
55
  }
56
56
  },
57
57
  "node_modules/@apollo/client": {
@@ -751,9 +751,9 @@
751
751
  }
752
752
  },
753
753
  "node_modules/@gogitcms/gitcms-local-darwin-arm64": {
754
- "version": "0.41.0",
755
- "resolved": "https://registry.npmjs.org/@gogitcms/gitcms-local-darwin-arm64/-/gitcms-local-darwin-arm64-0.41.0.tgz",
756
- "integrity": "sha512-5B5P0S3S714jXMTBMTM61h4H1hDS3TF0zCzZWUO59yGjJue0t7sIRyHvHjwcJJMYrdzBUKmX6LiGRnd5nqLI2Q==",
754
+ "version": "0.42.0",
755
+ "resolved": "https://registry.npmjs.org/@gogitcms/gitcms-local-darwin-arm64/-/gitcms-local-darwin-arm64-0.42.0.tgz",
756
+ "integrity": "sha512-rPnqWdexcwEU+jlQc8gRjbYI89uXjBpJYewD5wZz41bRFDVwW93klZw8tJHu108GHihzFh5WnVz0ga1AMWisIw==",
757
757
  "cpu": [
758
758
  "arm64"
759
759
  ],
@@ -764,9 +764,9 @@
764
764
  ]
765
765
  },
766
766
  "node_modules/@gogitcms/gitcms-local-darwin-x64": {
767
- "version": "0.41.0",
768
- "resolved": "https://registry.npmjs.org/@gogitcms/gitcms-local-darwin-x64/-/gitcms-local-darwin-x64-0.41.0.tgz",
769
- "integrity": "sha512-+o9YTQbf5luKlGeLcvVtRgR9oXJ8pZ4herLYaIgICp+rXpxSLEbevL2jlemYDwInG950zdEZ/CgFBMadtXdmWw==",
767
+ "version": "0.42.0",
768
+ "resolved": "https://registry.npmjs.org/@gogitcms/gitcms-local-darwin-x64/-/gitcms-local-darwin-x64-0.42.0.tgz",
769
+ "integrity": "sha512-cqziOuLSOFJOTGzXmVSrkXROtcvJm44Dro+fjB4iluTdRdtvmt/g4KmJEuATKYVpwBn7nJqKnv+eIwezwkaAFQ==",
770
770
  "cpu": [
771
771
  "x64"
772
772
  ],
@@ -776,6 +776,32 @@
776
776
  "darwin"
777
777
  ]
778
778
  },
779
+ "node_modules/@gogitcms/gitcms-local-linux-x64": {
780
+ "version": "0.42.0",
781
+ "resolved": "https://registry.npmjs.org/@gogitcms/gitcms-local-linux-x64/-/gitcms-local-linux-x64-0.42.0.tgz",
782
+ "integrity": "sha512-hjcY5voX1Do9+BDg9QAAC2SEVLswsm/rHt38teLHKoXCEcFa6E90O2sVj9DqTsxS3HKYJoI8dy92iGoU3kkNYA==",
783
+ "cpu": [
784
+ "x64"
785
+ ],
786
+ "license": "UNLICENSED",
787
+ "optional": true,
788
+ "os": [
789
+ "linux"
790
+ ]
791
+ },
792
+ "node_modules/@gogitcms/gitcms-local-win32-x64": {
793
+ "version": "0.42.0",
794
+ "resolved": "https://registry.npmjs.org/@gogitcms/gitcms-local-win32-x64/-/gitcms-local-win32-x64-0.42.0.tgz",
795
+ "integrity": "sha512-0Po8OeY9Q43zhvEc6JnzQTInlZ4VVcgli6MDOYxrenTSyTDQmKVySrKeZkqOnDZfZzibqkcLe/JNlR7WOgOHvw==",
796
+ "cpu": [
797
+ "x64"
798
+ ],
799
+ "license": "UNLICENSED",
800
+ "optional": true,
801
+ "os": [
802
+ "win32"
803
+ ]
804
+ },
779
805
  "node_modules/@graphql-typed-document-node/core": {
780
806
  "version": "3.2.0",
781
807
  "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
@@ -4032,9 +4058,9 @@
4032
4058
  "license": "MIT"
4033
4059
  },
4034
4060
  "node_modules/posthog-js": {
4035
- "version": "1.427.0",
4036
- "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.427.0.tgz",
4037
- "integrity": "sha512-FXrddoXpyLCcJAwIQ2Vhy6mwP6Ku2AdOlXJ5HUPS3BS8LmZvGDFe6zIBk4j8UGQdG4F9vIH10ASl8RKp5FAQEQ==",
4061
+ "version": "1.427.1",
4062
+ "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.427.1.tgz",
4063
+ "integrity": "sha512-jPKpF5qQS+SIzg2kJH6mTqkMEVY9YVBEywIccLFdl8A7zn+9aSnXQVv4xbF5gIjPtQe2MdcDugcyyJqfVrImLw==",
4038
4064
  "license": "(Apache-2.0 AND MIT)",
4039
4065
  "dependencies": {
4040
4066
  "@posthog/browser-common": "^0.7.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gogitcms/editor",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Terminal UI for setting up and running the Go·Git CMS content editor",
5
5
  "type": "module",
6
6
  "bin": {
@@ -65,10 +65,10 @@
65
65
  "@gogitcms/frontend": "workspace:*"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@gogitcms/gitcms-local-darwin-arm64": "0.41.0",
69
- "@gogitcms/gitcms-local-darwin-x64": "0.41.0",
70
- "@gogitcms/gitcms-local-linux-arm64": "0.41.0",
71
- "@gogitcms/gitcms-local-linux-x64": "0.41.0",
72
- "@gogitcms/gitcms-local-win32-x64": "0.41.0"
68
+ "@gogitcms/gitcms-local-darwin-arm64": "0.42.0",
69
+ "@gogitcms/gitcms-local-darwin-x64": "0.42.0",
70
+ "@gogitcms/gitcms-local-linux-arm64": "0.42.0",
71
+ "@gogitcms/gitcms-local-linux-x64": "0.42.0",
72
+ "@gogitcms/gitcms-local-win32-x64": "0.42.0"
73
73
  }
74
74
  }