@getrefino/react 0.1.0-rc.5 → 0.1.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.
@@ -0,0 +1,27 @@
1
+ import type { AllHTMLAttributes, JSX, ReactNode } from "react";
2
+ export type ArticleBodyElement = keyof JSX.IntrinsicElements;
3
+ export interface ArticleBodyProps extends Omit<AllHTMLAttributes<HTMLElement>, "id" | "as" | "children" | "contentEditable" | "dangerouslySetInnerHTML"> {
4
+ /** The body field's id, e.g. `blog.hello-world.body`. */
5
+ id: string;
6
+ /** Element to render. Defaults to `div`; use the element the article already had. */
7
+ as?: ArticleBodyElement | undefined;
8
+ /** DOM `id` attribute, since `id` is taken by the field id. */
9
+ htmlId?: string | undefined;
10
+ /** The article as the site renders it. Untouched in both modes. */
11
+ children?: ReactNode | undefined;
12
+ }
13
+ /**
14
+ * Marks the rendered article body as editable.
15
+ *
16
+ * In normal mode this is exactly the element the site already had, with the
17
+ * site's own rendered Markdown or MDX inside it: no wrapper, no attributes,
18
+ * no editor code path. In edit mode the same element gains an outline and a
19
+ * button that opens the source editor for the body.
20
+ *
21
+ * A body is not a sentence, so it deliberately does not become
22
+ * contentEditable the way `EditableText` does. Editing raw Markdown or MDX
23
+ * in place — next to JSX, imports and fenced code the renderer has already
24
+ * consumed — would be a different document from the one the reader sees.
25
+ * The panel edits the source; the page keeps rendering the article.
26
+ */
27
+ export declare function ArticleBody(props: ArticleBodyProps): JSX.Element;
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { splitDocumentFieldId } from "@getrefino/core";
4
+ import { createElement, useCallback, useSyncExternalStore } from "react";
5
+ import { useCopyContext } from "./context.js";
6
+ /**
7
+ * Marks the rendered article body as editable.
8
+ *
9
+ * In normal mode this is exactly the element the site already had, with the
10
+ * site's own rendered Markdown or MDX inside it: no wrapper, no attributes,
11
+ * no editor code path. In edit mode the same element gains an outline and a
12
+ * button that opens the source editor for the body.
13
+ *
14
+ * A body is not a sentence, so it deliberately does not become
15
+ * contentEditable the way `EditableText` does. Editing raw Markdown or MDX
16
+ * in place — next to JSX, imports and fenced code the renderer has already
17
+ * consumed — would be a different document from the one the reader sees.
18
+ * The panel edits the source; the page keeps rendering the article.
19
+ */
20
+ export function ArticleBody(props) {
21
+ const { id, as = "div", htmlId, children, ...rest } = props;
22
+ const context = useCopyContext();
23
+ const store = context?.store ?? null;
24
+ const subscribe = store ? store.subscribe : noopSubscribe;
25
+ const editing = useSyncExternalStore(subscribe, () => store?.getState().editing ?? false, () => false);
26
+ if (!store || !editing) {
27
+ return createElement(as, { ...rest, id: htmlId }, children);
28
+ }
29
+ return (_jsx(EditableArticleBody, { id: id, as: as, htmlId: htmlId, rest: rest, store: store, children: children }));
30
+ }
31
+ function noopSubscribe() {
32
+ return () => { };
33
+ }
34
+ function EditableArticleBody({ id, as, htmlId, rest, store, children }) {
35
+ const documentId = useSyncExternalStore(store.subscribe, () => splitDocumentFieldId(store.getState().documentIds, id)?.documentId ?? null, () => null);
36
+ const dirty = useSyncExternalStore(store.subscribe, () => store.isDirty(id), () => false);
37
+ const open = useCallback(() => {
38
+ if (documentId)
39
+ store.openPanel({ kind: "body", documentId });
40
+ }, [documentId, store]);
41
+ if (!documentId) {
42
+ return createElement(as, { ...rest, id: htmlId }, children);
43
+ }
44
+ return createElement(as, {
45
+ ...rest,
46
+ id: htmlId,
47
+ "data-refino-body": id,
48
+ "data-refino-state": dirty ? "dirty" : "idle",
49
+ }, _jsx("button", { type: "button", className: "refino-body__edit", onClick: open, children: dirty ? "Edit article body — unsaved" : "Edit article body" }), children);
50
+ }
@@ -0,0 +1,7 @@
1
+ import type { JSX } from "react";
2
+ /**
3
+ * The two editing surfaces that are not the rendered page: the article's
4
+ * metadata, and the article's source body. Rendered once by the provider,
5
+ * over the page, and only while edit mode is on.
6
+ */
7
+ export declare function ArticlePanel(): JSX.Element | null;
@@ -0,0 +1,93 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { BODY_FIELD_ID, META_FIELD_PREFIX, documentFieldId } from "@getrefino/core";
4
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
5
+ import { useCopyEditorState, useCopyStoreOrThrow } from "./context.js";
6
+ const LABELS = {
7
+ title: "Title",
8
+ subtitle: "Subtitle",
9
+ excerpt: "Excerpt",
10
+ description: "Description",
11
+ seoTitle: "SEO title",
12
+ seoDescription: "SEO description",
13
+ author: "Author",
14
+ date: "Date",
15
+ ctaLabel: "CTA label",
16
+ ctaText: "CTA text",
17
+ };
18
+ /** Fields that are usually a sentence or two rather than a line. */
19
+ const MULTILINE = new Set(["excerpt", "description", "seoDescription"]);
20
+ function fieldName(field) {
21
+ return field.id.startsWith(META_FIELD_PREFIX) ? field.id.slice(META_FIELD_PREFIX.length) : field.id;
22
+ }
23
+ function labelFor(field) {
24
+ const name = fieldName(field);
25
+ return LABELS[name] ?? name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase());
26
+ }
27
+ /**
28
+ * The two editing surfaces that are not the rendered page: the article's
29
+ * metadata, and the article's source body. Rendered once by the provider,
30
+ * over the page, and only while edit mode is on.
31
+ */
32
+ export function ArticlePanel() {
33
+ const { store } = useCopyStoreOrThrow();
34
+ const state = useCopyEditorState(store);
35
+ const panel = state.panel;
36
+ const article = panel ? state.documents[panel.documentId] : undefined;
37
+ const close = useCallback(() => store.closePanel(), [store]);
38
+ useEffect(() => {
39
+ if (!panel || typeof window === "undefined")
40
+ return;
41
+ const onKeyDown = (event) => {
42
+ if (event.key === "Escape") {
43
+ event.stopPropagation();
44
+ store.closePanel();
45
+ }
46
+ };
47
+ window.addEventListener("keydown", onKeyDown, true);
48
+ return () => window.removeEventListener("keydown", onKeyDown, true);
49
+ }, [panel, store]);
50
+ if (!panel || !article)
51
+ return null;
52
+ const heading = panel.kind === "body" ? "Article body" : "Article metadata";
53
+ return (_jsxs("div", { className: "refino-panel", role: "dialog", "aria-modal": "false", "aria-label": heading, children: [_jsxs("div", { className: "refino-panel__head", children: [_jsxs("div", { children: [_jsx("strong", { className: "refino-panel__title", children: heading }), article.path && (_jsx("span", { className: "refino-panel__path", title: article.path, children: article.path }))] }), _jsx("button", { type: "button", className: "refino-panel__close", onClick: close, children: "Done" })] }), panel.kind === "body" ? _jsx(BodyEditor, { article: article }) : _jsx(MetadataEditor, { article: article })] }));
54
+ }
55
+ /** One field of the draft, subscribed to on its own so typing re-renders one input. */
56
+ function useField(id) {
57
+ const { store } = useCopyStoreOrThrow();
58
+ const value = useSyncExternalStore(store.subscribe, () => store.getValue(id) ?? "", () => store.getValue(id) ?? "");
59
+ const dirty = useSyncExternalStore(store.subscribe, () => store.isDirty(id), () => false);
60
+ return { value, dirty };
61
+ }
62
+ function BodyEditor({ article }) {
63
+ const { store } = useCopyStoreOrThrow();
64
+ const id = documentFieldId(article.documentId, BODY_FIELD_ID);
65
+ const { value, dirty } = useField(id);
66
+ const field = article.fields.find((candidate) => candidate.id === BODY_FIELD_ID);
67
+ const textarea = useRef(null);
68
+ useEffect(() => {
69
+ textarea.current?.focus();
70
+ }, []);
71
+ if (field && !field.editable) {
72
+ return (_jsx("div", { className: "refino-panel__body", children: _jsxs("p", { className: "refino-panel__notice", role: "alert", children: ["Refino cannot edit this article\u2019s body: ", field.reason, ". Nothing has been changed."] }) }));
73
+ }
74
+ return (_jsxs("div", { className: "refino-panel__body", children: [_jsxs("p", { className: "refino-panel__hint", children: ["This is the article\u2019s ", article.sourceType === "mdx" ? "MDX" : "Markdown", " source, exactly as it is in", " ", article.path ?? "the repository", ". Components, imports, expressions and code fences are saved back unchanged; edit the prose around them."] }), _jsx("textarea", { ref: textarea, className: "refino-panel__source", spellCheck: false, value: value, "aria-label": "Article source", onChange: (event) => store.setValue(id, event.target.value) }), _jsxs("div", { className: "refino-panel__actions", children: [_jsx("span", { className: "refino-panel__state", children: dirty ? "This body has unsaved changes" : "This body is unchanged" }), _jsx("button", { type: "button", className: "refino-toolbar__button", onClick: () => store.revertValue(id), disabled: !dirty, children: "Revert body" })] })] }));
75
+ }
76
+ function MetadataEditor({ article }) {
77
+ const { store } = useCopyStoreOrThrow();
78
+ const fields = article.fields.filter((field) => field.id !== BODY_FIELD_ID);
79
+ if (fields.length === 0) {
80
+ return (_jsx("div", { className: "refino-panel__body", children: _jsxs("p", { className: "refino-panel__notice", children: ["Refino found no metadata it recognizes in this article\u2019s frontmatter. Map the fields you want in the ", _jsx("code", { children: "content" }), " block of ", _jsx("code", { children: "refino.config.json" }), "."] }) }));
81
+ }
82
+ return (_jsxs("div", { className: "refino-panel__body", children: [_jsx("div", { className: "refino-panel__fields", children: fields.map((field) => (_jsx(MetadataField, { article: article, field: field }, field.id))) }), _jsxs("div", { className: "refino-panel__actions", children: [_jsx("span", { className: "refino-panel__state", children: "Every other frontmatter key in this file is left exactly as it is." }), _jsx("button", { type: "button", className: "refino-toolbar__button", onClick: () => {
83
+ for (const field of fields)
84
+ store.revertValue(documentFieldId(article.documentId, field.id));
85
+ }, children: "Revert metadata" })] })] }));
86
+ }
87
+ function MetadataField({ article, field }) {
88
+ const { store } = useCopyStoreOrThrow();
89
+ const id = documentFieldId(article.documentId, field.id);
90
+ const { value, dirty } = useField(id);
91
+ const name = fieldName(field);
92
+ return (_jsxs("label", { className: "refino-panel__field", "data-refino-state": dirty ? "dirty" : "idle", children: [_jsxs("span", { className: "refino-panel__label", children: [labelFor(field), field.key && _jsx("code", { className: "refino-panel__key", children: field.key })] }), MULTILINE.has(name) ? (_jsx("textarea", { className: "refino-panel__input refino-panel__input--multiline", value: value, disabled: !field.editable, onChange: (event) => store.setValue(id, event.target.value) })) : (_jsx("input", { type: "text", className: "refino-panel__input", value: value, disabled: !field.editable, onChange: (event) => store.setValue(id, event.target.value) })), !field.editable && _jsxs("span", { className: "refino-panel__reason", children: ["Read-only: ", field.reason, "."] })] }));
93
+ }
@@ -80,12 +80,36 @@ function EditableField({ id, as, htmlId, rest, value, store }) {
80
80
  rest.onInput?.(event);
81
81
  store.setValue(id, readText(event.currentTarget));
82
82
  }, [id, rest, store]);
83
+ /**
84
+ * Where this element points, when it points anywhere. Only an anchor has
85
+ * one, and it is the site's own markup, never anything Refino composed.
86
+ */
87
+ const href = typeof rest.href === "string" && rest.href.length > 0 ? rest.href : null;
88
+ /**
89
+ * Follow the link, in **this** tab.
90
+ *
91
+ * The editor session lives in `sessionStorage`, so a new tab is a tab
92
+ * without edit mode. Ctrl/Cmd-click normally opens one; here it is taken
93
+ * over and turned into an ordinary same-tab navigation, which keeps edit
94
+ * mode and still runs the unsaved-changes guard on the way out.
95
+ */
96
+ const follow = useCallback(() => {
97
+ if (!href || typeof window === "undefined")
98
+ return;
99
+ window.location.assign(href);
100
+ }, [href]);
83
101
  const onKeyDown = useCallback((event) => {
84
102
  rest.onKeyDown?.(event);
85
103
  if (event.defaultPrevented)
86
104
  return;
87
105
  const element = event.currentTarget;
88
- if (event.key === "Enter") {
106
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && href) {
107
+ // The keyboard's half of the same gesture. Without it a link's text
108
+ // is editable but the link itself is unreachable without a mouse.
109
+ event.preventDefault();
110
+ follow();
111
+ }
112
+ else if (event.key === "Enter") {
89
113
  event.preventDefault();
90
114
  element.blur();
91
115
  }
@@ -95,7 +119,7 @@ function EditableField({ id, as, htmlId, rest, value, store }) {
95
119
  store.setValue(id, startValueRef.current);
96
120
  element.blur();
97
121
  }
98
- }, [id, rest, store]);
122
+ }, [id, rest, store, href, follow]);
99
123
  const onPaste = useCallback((event) => {
100
124
  rest.onPaste?.(event);
101
125
  if (event.defaultPrevented)
@@ -112,10 +136,27 @@ function EditableField({ id, as, htmlId, rest, value, store }) {
112
136
  rest.onClick?.(event);
113
137
  // Links and buttons must not navigate or submit while editing words.
114
138
  event.preventDefault();
115
- }, [rest]);
139
+ /**
140
+ * Except when the owner asks for it. A site's own navigation is
141
+ * usually `EditableText` too, so swallowing every click left an owner
142
+ * in edit mode stranded on whichever page they opened: the words were
143
+ * editable and the site was not walkable, which is fatal for an
144
+ * article, because an article is by definition on another page.
145
+ *
146
+ * Ctrl/Cmd-click is the browser's own "follow it anyway" gesture and
147
+ * it is kept meaning exactly that. A plain click still edits.
148
+ */
149
+ if (event.metaKey || event.ctrlKey)
150
+ follow();
151
+ }, [rest, follow]);
116
152
  const describedBy = [rest["aria-describedby"], INSTRUCTIONS_ID].filter(Boolean).join(" ");
117
153
  return createElement(as, {
118
154
  ...rest,
155
+ // The site's own title wins; this is only the hint an editable link
156
+ // needs, shown exactly when the owner is about to click it.
157
+ ...(href && rest.title === undefined
158
+ ? { title: "Ctrl-click (\u2318-click on a Mac) to follow this link" }
159
+ : {}),
119
160
  ref,
120
161
  id: htmlId,
121
162
  contentEditable: plaintextOnly ? "plaintext-only" : true,
@@ -0,0 +1,37 @@
1
+ import type { FieldId } from "@getrefino/core";
2
+ import type { JSX } from "react";
3
+ export interface RefinoArticleProps {
4
+ /**
5
+ * The article's id, e.g. `blog.hello-world`. Produced by
6
+ * `documentIdForPath` in `@getrefino/core/documents` from the source path
7
+ * the page already knows. A path is never sent to the server.
8
+ */
9
+ documentId: string;
10
+ /**
11
+ * The field values this page rendered the article with, e.g.
12
+ * `{ "meta.title": post.title, body: post.body }`. Edit mode shows these
13
+ * until the canonical version has loaded, and keeps them for fields
14
+ * Refino can read but not write.
15
+ */
16
+ fields: Readonly<Record<FieldId, string>>;
17
+ }
18
+ /**
19
+ * Declares that this page renders an article, so the editor can load it,
20
+ * hold a draft of it and save it.
21
+ *
22
+ * It renders nothing. Put it in the article page, wherever is convenient —
23
+ * the provider usually lives in the root layout, which cannot know which
24
+ * article a route is showing, and this is how the page tells it.
25
+ *
26
+ * Registration happens on mount, which is after the server rendered the
27
+ * page, so give each `EditableText` for an article field the page's own
28
+ * value as its child:
29
+ *
30
+ * ```tsx
31
+ * <EditableText id={`${documentId}.meta.title`} as="h1">{fields["meta.title"]}</EditableText>
32
+ * ```
33
+ *
34
+ * The server-rendered HTML is then already correct, and the editor replaces
35
+ * it with the canonical value once it has loaded. Both are the same file.
36
+ */
37
+ export declare function RefinoArticle({ documentId, fields }: RefinoArticleProps): JSX.Element | null;
@@ -0,0 +1,43 @@
1
+ "use client";
2
+ import { useEffect, useMemo } from "react";
3
+ import { useCopyContext } from "./context.js";
4
+ /**
5
+ * Declares that this page renders an article, so the editor can load it,
6
+ * hold a draft of it and save it.
7
+ *
8
+ * It renders nothing. Put it in the article page, wherever is convenient —
9
+ * the provider usually lives in the root layout, which cannot know which
10
+ * article a route is showing, and this is how the page tells it.
11
+ *
12
+ * Registration happens on mount, which is after the server rendered the
13
+ * page, so give each `EditableText` for an article field the page's own
14
+ * value as its child:
15
+ *
16
+ * ```tsx
17
+ * <EditableText id={`${documentId}.meta.title`} as="h1">{fields["meta.title"]}</EditableText>
18
+ * ```
19
+ *
20
+ * The server-rendered HTML is then already correct, and the editor replaces
21
+ * it with the canonical value once it has loaded. Both are the same file.
22
+ */
23
+ export function RefinoArticle({ documentId, fields }) {
24
+ const context = useCopyContext();
25
+ const store = context?.store ?? null;
26
+ // Registration is by value: a client-side navigation to another article
27
+ // re-registers, and a re-render with the same values does nothing.
28
+ const signature = useMemo(() => JSON.stringify([
29
+ documentId,
30
+ Object.keys(fields)
31
+ .sort()
32
+ .map((key) => [key, fields[key]]),
33
+ ]), [documentId, fields]);
34
+ useEffect(() => {
35
+ if (!store)
36
+ return;
37
+ store.addDocument({ documentId, fields });
38
+ return () => store.removeDocument(documentId);
39
+ // `signature` stands in for `fields`, which is a fresh object every render.
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ }, [store, documentId, signature]);
42
+ return null;
43
+ }
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { getDirtyIds } from "@getrefino/core";
4
- import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
5
  import { CopyContext } from "./context.js";
6
+ import { ArticlePanel } from "./ArticlePanel.js";
6
7
  import { createCopyStore } from "./store.js";
7
8
  import { EDITOR_STYLES } from "./styles.js";
8
9
  import { CopyEditorToolbar } from "./Toolbar.js";
@@ -26,11 +27,27 @@ export function RefinoProvider(props) {
26
27
  useEffect(() => {
27
28
  store.setBaseContent(content);
28
29
  }, [content, store]);
30
+ /**
31
+ * Load the canonical copy once per editing session, and again when the
32
+ * articles on the page change (a client-side navigation to another post).
33
+ *
34
+ * Articles register from `<RefinoArticle>`, whose effect runs before this
35
+ * one on mount, so the first request already names them and the page is
36
+ * loaded in one round trip rather than two.
37
+ */
38
+ const documentKey = useSyncExternalStore(store.subscribe, () => store.getState().documentIds.join(","), () => "");
39
+ const loadedKey = useRef(null);
29
40
  useEffect(() => {
30
- if (!editing)
41
+ if (!editing) {
42
+ loadedKey.current = null;
31
43
  return;
44
+ }
45
+ const key = store.getState().documentIds.join(",");
46
+ if (loadedKey.current === key)
47
+ return;
48
+ loadedKey.current = key;
32
49
  void store.load();
33
- }, [editing, store]);
50
+ }, [editing, store, documentKey]);
34
51
  useEffect(() => {
35
52
  if (!editing || typeof window === "undefined")
36
53
  return;
@@ -54,5 +71,5 @@ export function RefinoProvider(props) {
54
71
  };
55
72
  }, [editing, store]);
56
73
  const value = useMemo(() => ({ store, onExit, signInHref, updates }), [store, onExit, signInHref, updates]);
57
- return (_jsxs(CopyContext.Provider, { value: value, children: [children, editing && (_jsxs(_Fragment, { children: [_jsx("style", { "data-refino-styles": "", children: EDITOR_STYLES }), toolbar && _jsx(CopyEditorToolbar, {})] }))] }));
74
+ return (_jsxs(CopyContext.Provider, { value: value, children: [children, editing && (_jsxs(_Fragment, { children: [_jsx("style", { "data-refino-styles": "", children: EDITOR_STYLES }), _jsx(ArticlePanel, {}), toolbar && _jsx(CopyEditorToolbar, {})] }))] }));
58
75
  }
package/dist/Toolbar.js CHANGED
@@ -15,12 +15,23 @@ function plural(count, word) {
15
15
  */
16
16
  export function CopyEditorToolbar() {
17
17
  const editor = useCopyEditor();
18
- const { state, dirtyIds } = editor;
18
+ const { state, dirtyIds, articles } = editor;
19
+ // V1 edits the article the page is showing. A page with several is
20
+ // supported by the model; the toolbar simply offers the first.
21
+ const article = articles[0];
19
22
  const dirtyCount = dirtyIds.length;
20
23
  const activeDirty = state.activeId !== null && dirtyIds.includes(state.activeId);
21
24
  const busy = state.status === "saving" || state.status === "loading";
22
25
  const update = useIntegrationUpdate(editor.updates, state.loaded);
23
26
  const updateButtonRef = useRef(null);
27
+ /**
28
+ * Whether the refused save was about an article rather than the copy file.
29
+ * The service layer names the document it refused and the store carries
30
+ * that answer here; an article-only save never reads or writes
31
+ * `copy.json`, so saying "the copy file changed" would name a file nothing
32
+ * had touched.
33
+ */
34
+ const conflictArticle = state.status === "conflict" ? (state.error?.documentId ?? null) : null;
24
35
  const onRevertSelected = useCallback(() => {
25
36
  if (state.activeId)
26
37
  editor.revertValue(state.activeId);
@@ -42,7 +53,7 @@ export function CopyEditorToolbar() {
42
53
  dotClass = "saved";
43
54
  break;
44
55
  case "conflict":
45
- statusText = "Repository changed";
56
+ statusText = conflictArticle ? "Article changed" : "Repository changed";
46
57
  dotClass = "conflict";
47
58
  break;
48
59
  case "error":
@@ -53,5 +64,7 @@ export function CopyEditorToolbar() {
53
64
  statusText = dirtyCount > 0 ? `${plural(dirtyCount, "unsaved change")}` : "No changes";
54
65
  dotClass = dirtyCount > 0 ? "dirty" : "";
55
66
  }
56
- return (_jsxs("div", { className: "refino-toolbar", role: "region", "aria-label": "Copy editor", children: [_jsx("p", { id: INSTRUCTIONS_ID, className: "refino-sr-only", children: "Editable text. Type to change it. Press Enter to finish or Escape to cancel this edit." }), _jsx(UpdatePanel, { control: update, saving: state.status === "saving", buttonRef: updateButtonRef }), state.status === "conflict" && (_jsxs("div", { className: "refino-toolbar__notice refino-toolbar__notice--conflict", role: "alert", children: [_jsx("p", { children: "The copy file changed in the repository while you were editing (an AI agent or developer probably pushed). Your edits were not saved and nothing was overwritten." }), _jsxs("p", { children: ["Reload the latest copy and your ", plural(dirtyCount, "unsaved edit"), " will be reapplied on top of it for you to review and save again."] })] })), state.status === "error" && state.error && (_jsxs("div", { className: "refino-toolbar__notice refino-toolbar__notice--error", role: "alert", children: [_jsx("p", { children: state.error.message }), _jsxs("p", { children: [_jsx("code", { children: state.error.code }), state.error.code === "UNAUTHORIZED" && (_jsxs(_Fragment, { children: [" ", _jsx("a", { className: "refino-toolbar__link", href: editor.signInHref, children: "Sign in again" })] }))] })] })), state.rebase && (_jsxs("div", { className: "refino-toolbar__notice", role: "status", children: [state.rebase.overlapping.length > 0 && (_jsxs("p", { children: ["Both you and the repository changed:", " ", state.rebase.overlapping.map((id, index) => (_jsxs("span", { children: [index > 0 ? ", " : "", _jsx("code", { children: id })] }, id))), ". Your wording is shown; review before saving."] })), state.rebase.dropped.length > 0 && (_jsxs("p", { children: ["Dropped edits for fields that no longer exist:", " ", state.rebase.dropped.map((id, index) => (_jsxs("span", { children: [index > 0 ? ", " : "", _jsx("code", { children: id })] }, id))), "."] }))] })), _jsxs("div", { className: "refino-toolbar__bar", role: "toolbar", "aria-label": "Copy editor controls", children: [_jsxs("span", { className: "refino-toolbar__status", "aria-live": "polite", children: [_jsx("span", { className: `refino-toolbar__dot${dotClass ? ` refino-toolbar__dot--${dotClass}` : ""}` }), statusText, state.status === "saved" && state.lastSave?.commitUrl && (_jsxs(_Fragment, { children: [" ", _jsx("a", { className: "refino-toolbar__link", href: state.lastSave.commitUrl, target: "_blank", rel: "noreferrer", children: "view commit" })] }))] }), state.activeId && (_jsx("span", { className: "refino-toolbar__key", title: state.activeId, children: state.activeId })), state.status === "conflict" ? (_jsx("button", { type: "button", className: "refino-toolbar__button refino-toolbar__button--primary", onClick: () => void editor.reloadLatest(), children: "Reload latest" })) : (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "refino-toolbar__button", onMouseDown: (event) => event.preventDefault(), onClick: onRevertSelected, disabled: !activeDirty || busy, title: "Revert the selected text to its saved value", children: "Revert" }), _jsx("button", { type: "button", className: "refino-toolbar__button", onClick: editor.revertAll, disabled: dirtyCount === 0 || busy, title: "Discard all unsaved changes", children: "Revert all" }), _jsx("button", { type: "button", className: "refino-toolbar__button refino-toolbar__button--primary", onClick: () => void editor.save(), disabled: dirtyCount === 0 || busy || !state.loaded, title: "Save all changes (Ctrl/Cmd+S)", children: state.status === "saving" ? "Saving…" : "Save" })] })), _jsx(UpdateButton, { control: update, buttonRef: updateButtonRef }), _jsx("button", { type: "button", className: "refino-toolbar__button", onClick: () => void editor.exit(), disabled: busy, children: "Exit" })] })] }));
67
+ return (_jsxs("div", { className: "refino-toolbar", role: "region", "aria-label": "Copy editor", children: [_jsx("p", { id: INSTRUCTIONS_ID, className: "refino-sr-only", children: "Editable text. Type to change it. Press Enter to finish or Escape to cancel this edit. On a link, Ctrl-Enter or Ctrl-click (Command on a Mac) follows the link instead, so you can walk the site to the page you want without leaving edit mode." }), _jsx(UpdatePanel, { control: update, saving: state.status === "saving", buttonRef: updateButtonRef }), state.status === "conflict" && (_jsxs("div", { className: "refino-toolbar__notice refino-toolbar__notice--conflict", role: "alert", children: [_jsxs("p", { children: [conflictArticle ? "This article" : "The copy file", " changed in the repository while you were editing (an AI agent or developer probably pushed). Your edits were not saved and nothing was overwritten."] }), _jsxs("p", { children: ["Reload the latest ", conflictArticle ? "version of the article" : "copy", " and your", " ", plural(dirtyCount, "unsaved edit"), " will be reapplied on top of it for you to review and save again."] })] })), state.status === "error" && state.error && (_jsxs("div", { className: "refino-toolbar__notice refino-toolbar__notice--error", role: "alert", children: [_jsx("p", { children: state.error.message }), _jsxs("p", { children: [_jsx("code", { children: state.error.code }), state.error.code === "UNAUTHORIZED" && (_jsxs(_Fragment, { children: [" ", _jsx("a", { className: "refino-toolbar__link", href: editor.signInHref, children: "Sign in again" })] }))] })] })), state.rebase && (_jsxs("div", { className: "refino-toolbar__notice", role: "status", children: [state.rebase.overlapping.length > 0 && (_jsxs("p", { children: ["Both you and the repository changed:", " ", state.rebase.overlapping.map((id, index) => (_jsxs("span", { children: [index > 0 ? ", " : "", _jsx("code", { children: id })] }, id))), ". Your wording is shown; review before saving."] })), state.rebase.dropped.length > 0 && (_jsxs("p", { children: ["Dropped edits for fields that no longer exist:", " ", state.rebase.dropped.map((id, index) => (_jsxs("span", { children: [index > 0 ? ", " : "", _jsx("code", { children: id })] }, id))), "."] }))] })), _jsxs("div", { className: "refino-toolbar__bar", role: "toolbar", "aria-label": "Copy editor controls", children: [_jsxs("span", { className: "refino-toolbar__status", "aria-live": "polite", children: [_jsx("span", { className: `refino-toolbar__dot${dotClass ? ` refino-toolbar__dot--${dotClass}` : ""}` }), statusText, state.status === "saved" && state.lastSave?.commitUrl && (_jsxs(_Fragment, { children: [" ", _jsx("a", { className: "refino-toolbar__link", href: state.lastSave.commitUrl, target: "_blank", rel: "noreferrer", children: "view commit" })] }))] }), article && state.status !== "conflict" && (_jsx("button", { type: "button", className: "refino-toolbar__button", onClick: () => state.panel?.kind === "metadata"
68
+ ? editor.closePanel()
69
+ : editor.openPanel({ kind: "metadata", documentId: article.documentId }), "aria-pressed": state.panel?.kind === "metadata", title: "Edit this article's title, excerpt, SEO fields and dates", children: "Article metadata" })), state.activeId && (_jsx("span", { className: "refino-toolbar__key", title: state.activeId, children: state.activeId })), state.status === "conflict" ? (_jsx("button", { type: "button", className: "refino-toolbar__button refino-toolbar__button--primary", onClick: () => void editor.reloadLatest(), children: "Reload latest" })) : (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "refino-toolbar__button", onMouseDown: (event) => event.preventDefault(), onClick: onRevertSelected, disabled: !activeDirty || busy, title: "Revert the selected text to its saved value", children: "Revert" }), _jsx("button", { type: "button", className: "refino-toolbar__button", onClick: editor.revertAll, disabled: dirtyCount === 0 || busy, title: "Discard all unsaved changes", children: "Revert all" }), _jsx("button", { type: "button", className: "refino-toolbar__button refino-toolbar__button--primary", onClick: () => void editor.save(), disabled: dirtyCount === 0 || busy || !state.loaded, title: "Save all changes (Ctrl/Cmd+S)", children: state.status === "saving" ? "Saving…" : "Save" })] })), _jsx(UpdateButton, { control: update, buttonRef: updateButtonRef }), _jsx("button", { type: "button", className: "refino-toolbar__button", onClick: () => void editor.exit(), disabled: busy, children: "Exit" })] })] }));
57
70
  }
package/dist/index.d.ts CHANGED
@@ -5,9 +5,13 @@ export type { EditableTextElement, EditableTextProps } from "./EditableText.js";
5
5
  export { CopyEditorToolbar } from "./Toolbar.js";
6
6
  export { useCopyEditor } from "./useCopyEditor.js";
7
7
  export type { CopyEditor } from "./useCopyEditor.js";
8
+ export { ArticleBody } from "./ArticleBody.js";
9
+ export type { ArticleBodyElement, ArticleBodyProps } from "./ArticleBody.js";
10
+ export { RefinoArticle } from "./RefinoArticle.js";
11
+ export type { RefinoArticleProps } from "./RefinoArticle.js";
8
12
  export { createCopyStore } from "./store.js";
9
- export type { CopyEditorState, CopyRequestHeaders, CopyStore, CopyStoreOptions, EditorError, EditorStatus, LastSave, RebaseInfo, } from "./store.js";
10
- export type { EditorEvent, EditorEventName, EditorEventProperties, EditorEventSink } from "./telemetry.js";
13
+ export type { ArticleDocument, ArticleState, CopyEditorState, EditorPanel, CopyRequestHeaders, CopyStore, CopyStoreOptions, EditorError, EditorStatus, LastSave, RebaseInfo, } from "./store.js";
14
+ export type { EditorEvent, EditorEventName, EditorEventProperties, EditorEventSink, EditorSurface, } from "./telemetry.js";
11
15
  export { readUpdateResult, readUpdateStatus } from "./updates.js";
12
16
  export type { IntegrationUpdateResult, IntegrationUpdateSource, IntegrationUpdateStatus, UpdateUrgency } from "./updates.js";
13
17
  export { EDITOR_STYLES } from "./styles.js";
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@ export { RefinoProvider } from "./RefinoProvider.js";
2
2
  export { EditableText } from "./EditableText.js";
3
3
  export { CopyEditorToolbar } from "./Toolbar.js";
4
4
  export { useCopyEditor } from "./useCopyEditor.js";
5
+ export { ArticleBody } from "./ArticleBody.js";
6
+ export { RefinoArticle } from "./RefinoArticle.js";
5
7
  export { createCopyStore } from "./store.js";
6
8
  export { readUpdateResult, readUpdateStatus } from "./updates.js";
7
9
  export { EDITOR_STYLES } from "./styles.js";
package/dist/store.d.ts CHANGED
@@ -1,9 +1,18 @@
1
- import type { ContentId, ContentSnapshot, CopyContent, DraftState } from "@getrefino/core";
1
+ import type { ContentId, ContentSnapshot, ContentSourceType, CopyContent, DocumentFieldInfo, DocumentId, DraftState, FieldId } from "@getrefino/core";
2
2
  import type { EditorEventSink } from "./telemetry.js";
3
3
  export type EditorStatus = "idle" | "loading" | "saving" | "saved" | "error" | "conflict";
4
4
  export interface EditorError {
5
5
  readonly code: string;
6
6
  readonly message: string;
7
+ /**
8
+ * The article this failure is about, when it is about one.
9
+ *
10
+ * The service layer already knows which document it refused and says so;
11
+ * this is that answer carried through, so the toolbar can tell the owner
12
+ * that their *article* moved rather than reporting every conflict as a
13
+ * copy-file conflict. An id, never a path.
14
+ */
15
+ readonly documentId?: DocumentId;
7
16
  }
8
17
  export interface LastSave {
9
18
  readonly at: number;
@@ -14,6 +23,32 @@ export interface RebaseInfo {
14
23
  readonly overlapping: readonly ContentId[];
15
24
  readonly dropped: readonly ContentId[];
16
25
  }
26
+ /**
27
+ * An article rendered on this page, with the field values it rendered with.
28
+ * The page already read the file; passing those values means normal mode
29
+ * needs no editor code path and edit mode has something to show before the
30
+ * canonical version has loaded.
31
+ */
32
+ export interface ArticleDocument {
33
+ readonly documentId: DocumentId;
34
+ /** e.g. `{ "meta.title": "…", "meta.excerpt": "…", body: "…" }`. */
35
+ readonly fields: Readonly<Record<FieldId, string>>;
36
+ }
37
+ /** What the editor knows about one article. */
38
+ export interface ArticleState {
39
+ readonly documentId: DocumentId;
40
+ /** Null until the canonical article has loaded; a save is refused without it. */
41
+ readonly revision: string | null;
42
+ readonly path: string | null;
43
+ readonly sourceType: ContentSourceType | null;
44
+ /** Empty until loaded. Includes fields Refino can read but not write. */
45
+ readonly fields: readonly DocumentFieldInfo[];
46
+ }
47
+ /** Which editing surface is open on top of the page. */
48
+ export interface EditorPanel {
49
+ readonly kind: "body" | "metadata";
50
+ readonly documentId: DocumentId;
51
+ }
17
52
  /**
18
53
  * Everything the editor UI needs. Plain data, replaced immutably on every
19
54
  * change so React's `useSyncExternalStore` can subscribe to slices of it.
@@ -21,7 +56,7 @@ export interface RebaseInfo {
21
56
  export interface CopyEditorState {
22
57
  readonly editing: boolean;
23
58
  readonly draft: DraftState;
24
- /** Revision the draft is based on; null until the canonical copy has loaded. */
59
+ /** Revision the copy file draft is based on; null until the canonical copy has loaded. */
25
60
  readonly revision: string | null;
26
61
  readonly loaded: boolean;
27
62
  readonly status: EditorStatus;
@@ -31,6 +66,10 @@ export interface CopyEditorState {
31
66
  readonly activeId: ContentId | null;
32
67
  readonly lastSave: LastSave | null;
33
68
  readonly rebase: RebaseInfo | null;
69
+ /** Articles on this page, in the order the host declared them. */
70
+ readonly documentIds: readonly DocumentId[];
71
+ readonly documents: Readonly<Record<DocumentId, ArticleState>>;
72
+ readonly panel: EditorPanel | null;
34
73
  }
35
74
  export type CopyRequestHeaders = Readonly<Record<string, string>> | (() => Readonly<Record<string, string>>);
36
75
  export interface CopyStoreOptions {
@@ -63,7 +102,16 @@ export interface CopyStore {
63
102
  setActive(id: ContentId | null): void;
64
103
  /** Replace base content in normal mode (host re-rendered with new props). */
65
104
  setBaseContent(content: CopyContent): void;
105
+ /**
106
+ * Declare an article rendered on this page. Idempotent, so a component may
107
+ * call it on every mount; `<RefinoArticle>` does exactly that.
108
+ */
109
+ addDocument(document: ArticleDocument): void;
110
+ /** Withdraw an article: the page navigated away from it. */
111
+ removeDocument(documentId: DocumentId): void;
66
112
  setEditing(editing: boolean): void;
113
+ openPanel(panel: EditorPanel): void;
114
+ closePanel(): void;
67
115
  load(): Promise<void>;
68
116
  save(): Promise<void>;
69
117
  /** After a conflict: move the draft onto the latest upstream copy. */
package/dist/store.js CHANGED
@@ -1,4 +1,20 @@
1
- import { createDraft, getDirtyIds, getDraftValue, hasCopy, isDraftValueDirty, rebaseDraft, revertAllDraft, revertDraftValue, setDraftValue, } from "@getrefino/core";
1
+ import { BODY_FIELD_ID, createDraft, documentFieldId, getDirtyIds, getDraftValue, hasCopy, isDraftValueDirty, rebaseDraft, revertAllDraft, revertDraftValue, setDraftValue, splitDocumentFieldId, } from "@getrefino/core";
2
+ /** The flat ids one article's rendered values are held under. */
3
+ function initialFieldValues(document) {
4
+ const values = {};
5
+ for (const [fieldId, value] of Object.entries(document.fields)) {
6
+ values[documentFieldId(document.documentId, fieldId)] = value;
7
+ }
8
+ return values;
9
+ }
10
+ function sameFields(a, b) {
11
+ const aKeys = Object.keys(a.fields);
12
+ const bKeys = Object.keys(b.fields);
13
+ return aKeys.length === bKeys.length && aKeys.every((key) => a.fields[key] === b.fields[key]);
14
+ }
15
+ function emptyArticleState(documentId) {
16
+ return { documentId, revision: null, path: null, sourceType: null, fields: [] };
17
+ }
2
18
  export function createCopyStore(options) {
3
19
  const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
4
20
  const listeners = new Set();
@@ -18,6 +34,12 @@ export function createCopyStore(options) {
18
34
  * back must.
19
35
  */
20
36
  let openedReported = false;
37
+ /** The last canonical copy, and the last canonical articles, kept apart so
38
+ * either can be refreshed without discarding the other. */
39
+ let copyContent = options.content;
40
+ /** What the page rendered each article with, by article. */
41
+ const declared = new Map();
42
+ let initialFields = {};
21
43
  function report(event) {
22
44
  const sink = options.onEvent;
23
45
  if (!sink)
@@ -43,20 +65,74 @@ export function createCopyStore(options) {
43
65
  activeId: null,
44
66
  lastSave: null,
45
67
  rebase: null,
68
+ documentIds: [],
69
+ documents: {},
70
+ panel: null,
46
71
  };
47
72
  function update(patch) {
48
73
  state = { ...state, ...patch };
49
74
  for (const listener of listeners)
50
75
  listener();
51
76
  }
77
+ /**
78
+ * The draft's base: the canonical copy, then every article field. A field
79
+ * Refino can read but not write keeps whatever the page rendered with, so
80
+ * edit mode never shows a placeholder where the article shows a value.
81
+ */
82
+ function mergedBase(documents) {
83
+ const base = { ...copyContent, ...initialFields };
84
+ for (const article of Object.values(documents)) {
85
+ for (const field of article.fields) {
86
+ const id = documentFieldId(article.documentId, field.id);
87
+ if (field.editable || !(id in initialFields))
88
+ base[id] = field.value;
89
+ }
90
+ }
91
+ return base;
92
+ }
93
+ function rebuildDraft(documents, keepChanges) {
94
+ const base = mergedBase(documents);
95
+ if (!keepChanges) {
96
+ update({ documents, draft: createDraft(base), rebase: null });
97
+ return;
98
+ }
99
+ const result = rebaseDraft(state.draft, base);
100
+ update({
101
+ documents,
102
+ draft: result.draft,
103
+ rebase: result.overlapping.length > 0 || result.dropped.length > 0
104
+ ? { overlapping: result.overlapping, dropped: result.dropped }
105
+ : null,
106
+ });
107
+ }
108
+ function articleStatesFrom(snapshots) {
109
+ const documents = { ...state.documents };
110
+ for (const snapshot of snapshots) {
111
+ documents[snapshot.documentId] = {
112
+ documentId: snapshot.documentId,
113
+ revision: snapshot.revision,
114
+ path: snapshot.path,
115
+ sourceType: snapshot.sourceType,
116
+ fields: snapshot.fields,
117
+ };
118
+ }
119
+ return documents;
120
+ }
52
121
  function extraHeaders() {
53
122
  const headers = options.headers;
54
123
  return typeof headers === "function" ? headers() : (headers ?? {});
55
124
  }
56
- async function request(init) {
125
+ /** The load URL: the endpoint, plus the articles this page is showing. */
126
+ function loadUrl() {
127
+ if (state.documentIds.length === 0)
128
+ return options.endpoint;
129
+ const separator = options.endpoint.includes("?") ? "&" : "?";
130
+ return `${options.endpoint}${separator}documents=${encodeURIComponent(state.documentIds.join(","))}`;
131
+ }
132
+ async function request(url, init) {
57
133
  let response;
58
134
  try {
59
- response = await doFetch(options.endpoint, {
135
+ response = await doFetch(url, {
60
136
  credentials: "same-origin",
61
137
  ...init,
62
138
  headers: { accept: "application/json", ...extraHeaders(), ...(init?.headers ?? {}) },
@@ -87,21 +163,17 @@ export function createCopyStore(options) {
87
163
  },
88
164
  };
89
165
  }
90
- function applySnapshot(snapshot, keepChanges) {
91
- if (keepChanges) {
92
- const result = rebaseDraft(state.draft, snapshot.content);
93
- update({
94
- draft: result.draft,
95
- revision: snapshot.revision,
96
- loaded: true,
97
- rebase: result.overlapping.length > 0 || result.dropped.length > 0
98
- ? { overlapping: result.overlapping, dropped: result.dropped }
99
- : null,
100
- });
101
- }
102
- else {
103
- update({ draft: createDraft(snapshot.content), revision: snapshot.revision, loaded: true, rebase: null });
104
- }
166
+ function applySnapshot(snapshot, documents, keepChanges) {
167
+ copyContent = snapshot.content;
168
+ update({ revision: snapshot.revision, loaded: true });
169
+ rebuildDraft(documents ? articleStatesFrom(documents) : state.documents, keepChanges);
170
+ }
171
+ /** Which surface an id is edited on. A low-cardinality enum, never the id. */
172
+ function surfaceOf(id) {
173
+ const split = splitDocumentFieldId(state.documentIds, id);
174
+ if (!split)
175
+ return "inline_copy";
176
+ return split.fieldId === BODY_FIELD_ID ? "article_body" : "article_metadata";
105
177
  }
106
178
  return {
107
179
  getState: () => state,
@@ -122,13 +194,14 @@ export function createCopyStore(options) {
122
194
  // The transition from a clean draft to a dirty one, not every
123
195
  // keystroke: typing a word produces one event, not twenty.
124
196
  const started = dirtyCount(state.draft) === 0 && dirtyCount(draft) > 0;
197
+ const surface = surfaceOf(id);
125
198
  update({
126
199
  draft,
127
200
  status: state.status === "saved" || state.status === "error" ? "idle" : state.status,
128
201
  error: state.status === "error" ? null : state.error,
129
202
  });
130
203
  if (started)
131
- report({ name: "edit_started", properties: {} });
204
+ report({ name: "edit_started", properties: { edit_surface: surface } });
132
205
  },
133
206
  revertValue(id) {
134
207
  const draft = revertDraftValue(state.draft, id);
@@ -150,7 +223,38 @@ export function createCopyStore(options) {
150
223
  setBaseContent(content) {
151
224
  if (state.editing)
152
225
  return;
153
- update({ draft: createDraft(content) });
226
+ copyContent = content;
227
+ update({ draft: createDraft(mergedBase(state.documents)) });
228
+ },
229
+ addDocument(document) {
230
+ const existing = declared.get(document.documentId);
231
+ if (existing && sameFields(existing, document))
232
+ return;
233
+ declared.set(document.documentId, document);
234
+ initialFields = { ...initialFields, ...initialFieldValues(document) };
235
+ const documents = { ...state.documents };
236
+ documents[document.documentId] ??= emptyArticleState(document.documentId);
237
+ const documentIds = state.documentIds.includes(document.documentId)
238
+ ? state.documentIds
239
+ : [...state.documentIds, document.documentId];
240
+ update({ documentIds });
241
+ // Unsaved work on another article on the same page is kept.
242
+ rebuildDraft(documents, true);
243
+ },
244
+ removeDocument(documentId) {
245
+ if (!declared.has(documentId))
246
+ return;
247
+ declared.delete(documentId);
248
+ initialFields = {};
249
+ for (const document of declared.values())
250
+ Object.assign(initialFields, initialFieldValues(document));
251
+ const documents = { ...state.documents };
252
+ delete documents[documentId];
253
+ update({
254
+ documentIds: state.documentIds.filter((id) => id !== documentId),
255
+ panel: state.panel?.documentId === documentId ? null : state.panel,
256
+ });
257
+ rebuildDraft(documents, true);
154
258
  },
155
259
  setEditing(editing) {
156
260
  if (state.editing === editing)
@@ -159,13 +263,22 @@ export function createCopyStore(options) {
159
263
  // reports its own opening, whether or not the provider ever unmounted.
160
264
  if (!editing)
161
265
  openedReported = false;
162
- update({ editing });
266
+ update({ editing, panel: editing ? state.panel : null });
267
+ },
268
+ openPanel(panel) {
269
+ if (!state.documentIds.includes(panel.documentId))
270
+ return;
271
+ update({ panel });
272
+ },
273
+ closePanel() {
274
+ if (state.panel)
275
+ update({ panel: null });
163
276
  },
164
277
  async load() {
165
278
  update({ status: "loading", error: null });
166
- const body = await request({ method: "GET" });
279
+ const body = await request(loadUrl(), { method: "GET" });
167
280
  if (body.ok && "snapshot" in body) {
168
- applySnapshot(body.snapshot, getDirtyIds(state.draft).length > 0);
281
+ applySnapshot(body.snapshot, body.documents, getDirtyIds(state.draft).length > 0);
169
282
  update({ status: "idle" });
170
283
  // The editor is open once the canonical copy is actually in hand.
171
284
  if (state.editing && !openedReported) {
@@ -180,41 +293,84 @@ export function createCopyStore(options) {
180
293
  async save() {
181
294
  if (state.status === "saving")
182
295
  return;
183
- if (state.revision === null) {
184
- update({
185
- status: "error",
186
- error: { code: "NOT_LOADED", message: "The latest copy has not loaded yet. Try again." },
187
- });
188
- return;
189
- }
190
296
  const changes = state.draft.changes;
191
297
  const changedCount = Object.keys(changes).length;
192
298
  if (changedCount === 0) {
193
299
  update({ status: "saved", lastSave: { at: Date.now(), status: "unchanged" }, error: null });
194
300
  return;
195
301
  }
302
+ // Split the one draft into the copy file and each article. Ids carry
303
+ // their own document, so this needs no bookkeeping while typing.
304
+ const copyChanges = {};
305
+ const byDocument = new Map();
306
+ for (const [id, value] of Object.entries(changes)) {
307
+ const split = splitDocumentFieldId(state.documentIds, id);
308
+ if (!split) {
309
+ copyChanges[id] = value;
310
+ continue;
311
+ }
312
+ const fields = byDocument.get(split.documentId) ?? {};
313
+ fields[split.fieldId] = value;
314
+ byDocument.set(split.documentId, fields);
315
+ }
316
+ const copyCount = Object.keys(copyChanges).length;
317
+ if (copyCount > 0 && state.revision === null) {
318
+ update({ status: "error", error: { code: "NOT_LOADED", message: "The latest copy has not loaded yet. Try again." } });
319
+ return;
320
+ }
321
+ const documents = [];
322
+ for (const [documentId, fields] of byDocument) {
323
+ const revision = state.documents[documentId]?.revision;
324
+ if (!revision) {
325
+ update({ status: "error", error: { code: "NOT_LOADED", message: "The latest article has not loaded yet. Try again." } });
326
+ return;
327
+ }
328
+ documents.push({ documentId, baseRevision: revision, changes: fields });
329
+ }
196
330
  update({ status: "saving", error: null, conflict: null });
197
- const body = await request({
331
+ const body = await request(options.endpoint, {
198
332
  method: "POST",
199
333
  headers: { "content-type": "application/json" },
200
- body: JSON.stringify({ baseRevision: state.revision, changes }),
334
+ body: JSON.stringify({
335
+ ...(copyCount > 0 ? { baseRevision: state.revision, changes: copyChanges } : {}),
336
+ ...(documents.length > 0 ? { documents } : {}),
337
+ }),
201
338
  });
202
339
  if (body.ok && "result" in body) {
203
340
  const { result } = body;
204
- applySnapshot(result.snapshot, false);
205
- const lastSave = result.status === "saved" && result.commit?.url
206
- ? { at: Date.now(), status: result.status, commitUrl: result.commit.url }
207
- : { at: Date.now(), status: result.status };
341
+ const documentResults = result.documents ?? [];
342
+ if (result.status === "documents") {
343
+ rebuildDraft(articleStatesFrom(documentResults.map((entry) => entry.snapshot)), false);
344
+ }
345
+ else {
346
+ applySnapshot(result.snapshot, documentResults.map((entry) => entry.snapshot), false);
347
+ }
348
+ const wrote = (result.status === "saved") || documentResults.some((entry) => entry.status === "saved");
349
+ const commitUrl = (result.status === "saved" ? result.commit?.url : undefined) ??
350
+ documentResults.find((entry) => entry.commit?.url)?.commit?.url;
351
+ const lastSave = commitUrl
352
+ ? { at: Date.now(), status: wrote ? "saved" : "unchanged", commitUrl }
353
+ : { at: Date.now(), status: wrote ? "saved" : "unchanged" };
208
354
  update({ status: "saved", lastSave });
209
355
  // Only a save that produced a commit. An unchanged re-save wrote
210
356
  // nothing, so it is not an edit.
211
- if (result.status === "saved") {
357
+ if (wrote) {
212
358
  report({ name: "edit_saved", properties: { changed_count: changedCount } });
213
359
  }
214
360
  return;
215
361
  }
216
362
  if (!body.ok && body.error.code === "CONFLICT") {
217
- update({ status: "conflict", conflict: body.error.current ?? null, error: body.error });
363
+ // Which thing moved. The server says so when it knows; a server that
364
+ // predates the field still leaves the answer unambiguous whenever the
365
+ // refused save named articles and no copy at all, which is every
366
+ // article-only save.
367
+ const onlyArticle = documents.length === 1 ? documents[0].documentId : null;
368
+ const documentId = body.error.documentId ?? (copyCount === 0 ? onlyArticle : null);
369
+ update({
370
+ status: "conflict",
371
+ conflict: body.error.current ?? null,
372
+ error: documentId ? { ...body.error, documentId } : body.error,
373
+ });
218
374
  return;
219
375
  }
220
376
  if (!body.ok) {
@@ -225,16 +381,18 @@ export function createCopyStore(options) {
225
381
  },
226
382
  async reloadLatest() {
227
383
  const known = state.conflict;
228
- if (known) {
229
- applySnapshot(known, true);
384
+ // A copy conflict comes back with the latest copy attached; an article
385
+ // conflict does not, so that case reloads everything.
386
+ if (known && state.documentIds.length === 0) {
387
+ applySnapshot(known, undefined, true);
230
388
  update({ status: "idle", conflict: null, error: null });
231
389
  return;
232
390
  }
233
391
  update({ status: "loading", error: null });
234
- const body = await request({ method: "GET" });
392
+ const body = await request(loadUrl(), { method: "GET" });
235
393
  if (body.ok && "snapshot" in body) {
236
- applySnapshot(body.snapshot, true);
237
- update({ status: "idle", conflict: null });
394
+ applySnapshot(body.snapshot, body.documents, true);
395
+ update({ status: "idle", conflict: null, error: null });
238
396
  }
239
397
  else if (!body.ok) {
240
398
  update({ status: "error", error: body.error });
package/dist/styles.d.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * `outline` and `box-shadow` never affect layout, so text reflows exactly
5
5
  * as it does for visitors.
6
6
  */
7
- export declare const EDITOR_STYLES = "\n[data-refino-id] {\n cursor: text;\n outline: 1px dashed transparent;\n outline-offset: 3px;\n border-radius: 2px;\n transition: outline-color 120ms ease, box-shadow 120ms ease;\n}\n[data-refino-id]:hover {\n outline-color: rgba(37, 99, 235, 0.6);\n}\n[data-refino-id][data-refino-state=\"dirty\"] {\n outline-color: rgba(217, 119, 6, 0.75);\n}\n[data-refino-id][data-refino-state=\"active\"],\n[data-refino-id]:focus,\n[data-refino-id]:focus-visible {\n outline: 2px solid #2563eb;\n outline-offset: 3px;\n box-shadow: 0 0 0 5px rgba(37, 99, 235, 0.14);\n}\n[data-refino-id]:empty::before {\n content: \"\\00a0\";\n}\n.refino-sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n.refino-toolbar {\n all: initial;\n position: fixed;\n left: 50%;\n bottom: 16px;\n transform: translateX(-50%);\n z-index: 2147483000;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 8px;\n max-width: calc(100vw - 24px);\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 13px;\n line-height: 1.3;\n color: #f9fafb;\n box-sizing: border-box;\n}\n.refino-toolbar *, .refino-toolbar *::before, .refino-toolbar *::after {\n box-sizing: border-box;\n font: inherit;\n color: inherit;\n}\n.refino-toolbar__bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: center;\n gap: 6px;\n padding: 8px 10px;\n background: #111827;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 999px;\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);\n}\n.refino-toolbar__status {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 0 8px;\n min-width: 0;\n white-space: nowrap;\n color: #d1d5db;\n}\n.refino-toolbar__dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: #6b7280;\n flex: none;\n}\n.refino-toolbar__dot--dirty { background: #f59e0b; }\n.refino-toolbar__dot--saving { background: #60a5fa; }\n.refino-toolbar__dot--saved { background: #34d399; }\n.refino-toolbar__dot--error, .refino-toolbar__dot--conflict { background: #f87171; }\n.refino-toolbar__key {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n color: #9ca3af;\n padding: 0 8px;\n max-width: 220px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.refino-toolbar__button {\n appearance: none;\n cursor: pointer;\n border: 1px solid transparent;\n border-radius: 999px;\n padding: 6px 12px;\n background: transparent;\n color: #e5e7eb;\n font-weight: 500;\n}\n.refino-toolbar__button:hover:not(:disabled) {\n background: rgba(255, 255, 255, 0.08);\n}\n.refino-toolbar__button:focus-visible {\n outline: 2px solid #93c5fd;\n outline-offset: 1px;\n}\n.refino-toolbar__button:disabled {\n opacity: 0.45;\n cursor: default;\n}\n.refino-toolbar__button--primary {\n background: #2563eb;\n color: #ffffff;\n}\n.refino-toolbar__button--primary:hover:not(:disabled) {\n background: #1d4ed8;\n}\n.refino-toolbar__notice {\n padding: 10px 14px;\n border-radius: 12px;\n background: #1f2937;\n border: 1px solid rgba(255, 255, 255, 0.12);\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);\n max-width: 560px;\n color: #f3f4f6;\n}\n.refino-toolbar__notice--error { border-color: rgba(248, 113, 113, 0.6); }\n.refino-toolbar__notice--conflict { border-color: rgba(251, 191, 36, 0.6); }\n.refino-toolbar__notice p { margin: 0 0 6px; }\n.refino-toolbar__notice p:last-child { margin: 0; }\n.refino-toolbar__notice code {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n}\n.refino-toolbar__button--update {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n border-color: rgba(147, 197, 253, 0.45);\n color: #dbeafe;\n}\n.refino-toolbar__dot--update { background: #60a5fa; }\n.refino-update {\n width: min(360px, calc(100vw - 24px));\n}\n.refino-update__title {\n font-weight: 600;\n font-size: 14px;\n}\n.refino-update__summary {\n margin: 0 0 8px;\n padding-left: 18px;\n}\n.refino-update__summary li { margin: 0 0 2px; }\n.refino-toolbar__notice p.refino-update__note {\n color: #9ca3af;\n font-size: 12px;\n}\n.refino-update__actions {\n display: flex;\n justify-content: flex-end;\n gap: 6px;\n margin-top: 10px;\n}\n.refino-toolbar__link {\n color: #93c5fd;\n text-decoration: underline;\n}\n@media (max-width: 520px) {\n .refino-toolbar { bottom: 8px; }\n .refino-toolbar__key { display: none; }\n}\n";
7
+ export declare const EDITOR_STYLES = "\n[data-refino-id] {\n cursor: text;\n outline: 1px dashed transparent;\n outline-offset: 3px;\n border-radius: 2px;\n transition: outline-color 120ms ease, box-shadow 120ms ease;\n}\n[data-refino-id]:hover {\n outline-color: rgba(37, 99, 235, 0.6);\n}\n[data-refino-id][data-refino-state=\"dirty\"] {\n outline-color: rgba(217, 119, 6, 0.75);\n}\n[data-refino-id][data-refino-state=\"active\"],\n[data-refino-id]:focus,\n[data-refino-id]:focus-visible {\n outline: 2px solid #2563eb;\n outline-offset: 3px;\n box-shadow: 0 0 0 5px rgba(37, 99, 235, 0.14);\n}\n[data-refino-id]:empty::before {\n content: \"\\00a0\";\n}\n.refino-sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n.refino-toolbar {\n all: initial;\n position: fixed;\n left: 50%;\n bottom: 16px;\n transform: translateX(-50%);\n z-index: 2147483000;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 8px;\n max-width: calc(100vw - 24px);\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 13px;\n line-height: 1.3;\n color: #f9fafb;\n box-sizing: border-box;\n}\n.refino-toolbar *, .refino-toolbar *::before, .refino-toolbar *::after {\n box-sizing: border-box;\n font: inherit;\n color: inherit;\n}\n.refino-toolbar__bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: center;\n gap: 6px;\n padding: 8px 10px;\n background: #111827;\n border: 1px solid rgba(255, 255, 255, 0.12);\n border-radius: 999px;\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);\n}\n.refino-toolbar__status {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 0 8px;\n min-width: 0;\n white-space: nowrap;\n color: #d1d5db;\n}\n.refino-toolbar__dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: #6b7280;\n flex: none;\n}\n.refino-toolbar__dot--dirty { background: #f59e0b; }\n.refino-toolbar__dot--saving { background: #60a5fa; }\n.refino-toolbar__dot--saved { background: #34d399; }\n.refino-toolbar__dot--error, .refino-toolbar__dot--conflict { background: #f87171; }\n.refino-toolbar__key {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n color: #9ca3af;\n padding: 0 8px;\n max-width: 220px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.refino-toolbar__button {\n appearance: none;\n cursor: pointer;\n border: 1px solid transparent;\n border-radius: 999px;\n padding: 6px 12px;\n background: transparent;\n color: #e5e7eb;\n font-weight: 500;\n}\n.refino-toolbar__button:hover:not(:disabled) {\n background: rgba(255, 255, 255, 0.08);\n}\n.refino-toolbar__button:focus-visible {\n outline: 2px solid #93c5fd;\n outline-offset: 1px;\n}\n.refino-toolbar__button:disabled {\n opacity: 0.45;\n cursor: default;\n}\n.refino-toolbar__button--primary {\n background: #2563eb;\n color: #ffffff;\n}\n.refino-toolbar__button--primary:hover:not(:disabled) {\n background: #1d4ed8;\n}\n.refino-toolbar__notice {\n padding: 10px 14px;\n border-radius: 12px;\n background: #1f2937;\n border: 1px solid rgba(255, 255, 255, 0.12);\n box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);\n max-width: 560px;\n color: #f3f4f6;\n}\n.refino-toolbar__notice--error { border-color: rgba(248, 113, 113, 0.6); }\n.refino-toolbar__notice--conflict { border-color: rgba(251, 191, 36, 0.6); }\n.refino-toolbar__notice p { margin: 0 0 6px; }\n.refino-toolbar__notice p:last-child { margin: 0; }\n.refino-toolbar__notice code {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n}\n.refino-toolbar__button--update {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n border-color: rgba(147, 197, 253, 0.45);\n color: #dbeafe;\n}\n.refino-toolbar__dot--update { background: #60a5fa; }\n.refino-update {\n width: min(360px, calc(100vw - 24px));\n}\n.refino-update__title {\n font-weight: 600;\n font-size: 14px;\n}\n.refino-update__summary {\n margin: 0 0 8px;\n padding-left: 18px;\n}\n.refino-update__summary li { margin: 0 0 2px; }\n.refino-toolbar__notice p.refino-update__note {\n color: #9ca3af;\n font-size: 12px;\n}\n.refino-update__actions {\n display: flex;\n justify-content: flex-end;\n gap: 6px;\n margin-top: 10px;\n}\n.refino-toolbar__link {\n color: #93c5fd;\n text-decoration: underline;\n}\n@media (max-width: 520px) {\n .refino-toolbar { bottom: 8px; }\n .refino-toolbar__key { display: none; }\n}\n\n[data-refino-body] {\n position: relative;\n outline: 1px dashed rgba(37, 99, 235, 0.45);\n outline-offset: 8px;\n border-radius: 2px;\n}\n[data-refino-body][data-refino-state=\"dirty\"] {\n outline-color: rgba(217, 119, 6, 0.8);\n}\n.refino-body__edit {\n all: initial;\n position: absolute;\n top: -14px;\n right: 0;\n z-index: 2147482000;\n cursor: pointer;\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 12px;\n font-weight: 500;\n line-height: 1;\n color: #ffffff;\n background: #2563eb;\n border-radius: 999px;\n padding: 6px 12px;\n box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);\n}\n.refino-body__edit:hover { background: #1d4ed8; }\n.refino-body__edit:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }\n[data-refino-body][data-refino-state=\"dirty\"] .refino-body__edit { background: #b45309; }\n.refino-panel {\n all: initial;\n position: fixed;\n right: 16px;\n bottom: 76px;\n z-index: 2147483100;\n display: flex;\n flex-direction: column;\n width: min(760px, calc(100vw - 32px));\n max-height: min(70vh, 720px);\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 13px;\n line-height: 1.45;\n color: #f3f4f6;\n background: #111827;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 14px;\n box-shadow: 0 24px 60px rgba(0, 0, 0, 0.42);\n box-sizing: border-box;\n overflow: hidden;\n}\n.refino-panel *, .refino-panel *::before, .refino-panel *::after {\n box-sizing: border-box;\n font: inherit;\n color: inherit;\n}\n.refino-panel__head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 12px 14px;\n border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n}\n.refino-panel__title { font-weight: 600; }\n.refino-panel__path {\n display: block;\n margin-top: 2px;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 11px;\n color: #9ca3af;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n max-width: 60ch;\n}\n.refino-panel__close {\n appearance: none;\n cursor: pointer;\n border: 1px solid transparent;\n border-radius: 999px;\n padding: 6px 14px;\n background: #2563eb;\n color: #ffffff;\n font-weight: 500;\n flex: none;\n}\n.refino-panel__close:hover { background: #1d4ed8; }\n.refino-panel__close:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }\n.refino-panel__body {\n display: flex;\n flex-direction: column;\n gap: 10px;\n padding: 12px 14px 14px;\n overflow: auto;\n min-height: 0;\n}\n.refino-panel__hint, .refino-panel__notice { margin: 0; color: #d1d5db; }\n.refino-panel__notice { color: #fcd34d; }\n.refino-panel__source {\n appearance: none;\n width: 100%;\n min-height: 320px;\n resize: vertical;\n padding: 12px;\n background: #0b1220;\n color: #e5e7eb;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 10px;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12.5px;\n line-height: 1.6;\n tab-size: 2;\n white-space: pre;\n overflow-wrap: normal;\n}\n.refino-panel__source:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }\n.refino-panel__fields {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));\n gap: 10px 14px;\n}\n.refino-panel__field { display: flex; flex-direction: column; gap: 4px; }\n.refino-panel__label {\n display: flex;\n align-items: baseline;\n gap: 6px;\n color: #d1d5db;\n font-size: 12px;\n}\n.refino-panel__key {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 11px;\n color: #6b7280;\n}\n.refino-panel__field[data-refino-state=\"dirty\"] .refino-panel__label { color: #fbbf24; }\n.refino-panel__input {\n appearance: none;\n width: 100%;\n padding: 8px 10px;\n background: #0b1220;\n color: #e5e7eb;\n border: 1px solid rgba(255, 255, 255, 0.14);\n border-radius: 8px;\n}\n.refino-panel__input--multiline { min-height: 72px; resize: vertical; line-height: 1.5; }\n.refino-panel__input:disabled { opacity: 0.55; cursor: not-allowed; }\n.refino-panel__input:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }\n.refino-panel__field[data-refino-state=\"dirty\"] .refino-panel__input { border-color: rgba(251, 191, 36, 0.7); }\n.refino-panel__reason { color: #9ca3af; font-size: 11px; }\n.refino-panel__actions {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n flex-wrap: wrap;\n}\n.refino-panel__state { color: #9ca3af; font-size: 12px; }\n@media (max-width: 520px) {\n .refino-panel { right: 8px; left: 8px; width: auto; bottom: 64px; max-height: 76vh; }\n}\n";
package/dist/styles.js CHANGED
@@ -187,4 +187,165 @@ export const EDITOR_STYLES = `
187
187
  .refino-toolbar { bottom: 8px; }
188
188
  .refino-toolbar__key { display: none; }
189
189
  }
190
+
191
+ [data-refino-body] {
192
+ position: relative;
193
+ outline: 1px dashed rgba(37, 99, 235, 0.45);
194
+ outline-offset: 8px;
195
+ border-radius: 2px;
196
+ }
197
+ [data-refino-body][data-refino-state="dirty"] {
198
+ outline-color: rgba(217, 119, 6, 0.8);
199
+ }
200
+ .refino-body__edit {
201
+ all: initial;
202
+ position: absolute;
203
+ top: -14px;
204
+ right: 0;
205
+ z-index: 2147482000;
206
+ cursor: pointer;
207
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
208
+ font-size: 12px;
209
+ font-weight: 500;
210
+ line-height: 1;
211
+ color: #ffffff;
212
+ background: #2563eb;
213
+ border-radius: 999px;
214
+ padding: 6px 12px;
215
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
216
+ }
217
+ .refino-body__edit:hover { background: #1d4ed8; }
218
+ .refino-body__edit:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
219
+ [data-refino-body][data-refino-state="dirty"] .refino-body__edit { background: #b45309; }
220
+ .refino-panel {
221
+ all: initial;
222
+ position: fixed;
223
+ right: 16px;
224
+ bottom: 76px;
225
+ z-index: 2147483100;
226
+ display: flex;
227
+ flex-direction: column;
228
+ width: min(760px, calc(100vw - 32px));
229
+ max-height: min(70vh, 720px);
230
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
231
+ font-size: 13px;
232
+ line-height: 1.45;
233
+ color: #f3f4f6;
234
+ background: #111827;
235
+ border: 1px solid rgba(255, 255, 255, 0.14);
236
+ border-radius: 14px;
237
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.42);
238
+ box-sizing: border-box;
239
+ overflow: hidden;
240
+ }
241
+ .refino-panel *, .refino-panel *::before, .refino-panel *::after {
242
+ box-sizing: border-box;
243
+ font: inherit;
244
+ color: inherit;
245
+ }
246
+ .refino-panel__head {
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: space-between;
250
+ gap: 12px;
251
+ padding: 12px 14px;
252
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
253
+ }
254
+ .refino-panel__title { font-weight: 600; }
255
+ .refino-panel__path {
256
+ display: block;
257
+ margin-top: 2px;
258
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
259
+ font-size: 11px;
260
+ color: #9ca3af;
261
+ overflow: hidden;
262
+ text-overflow: ellipsis;
263
+ white-space: nowrap;
264
+ max-width: 60ch;
265
+ }
266
+ .refino-panel__close {
267
+ appearance: none;
268
+ cursor: pointer;
269
+ border: 1px solid transparent;
270
+ border-radius: 999px;
271
+ padding: 6px 14px;
272
+ background: #2563eb;
273
+ color: #ffffff;
274
+ font-weight: 500;
275
+ flex: none;
276
+ }
277
+ .refino-panel__close:hover { background: #1d4ed8; }
278
+ .refino-panel__close:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
279
+ .refino-panel__body {
280
+ display: flex;
281
+ flex-direction: column;
282
+ gap: 10px;
283
+ padding: 12px 14px 14px;
284
+ overflow: auto;
285
+ min-height: 0;
286
+ }
287
+ .refino-panel__hint, .refino-panel__notice { margin: 0; color: #d1d5db; }
288
+ .refino-panel__notice { color: #fcd34d; }
289
+ .refino-panel__source {
290
+ appearance: none;
291
+ width: 100%;
292
+ min-height: 320px;
293
+ resize: vertical;
294
+ padding: 12px;
295
+ background: #0b1220;
296
+ color: #e5e7eb;
297
+ border: 1px solid rgba(255, 255, 255, 0.14);
298
+ border-radius: 10px;
299
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
300
+ font-size: 12.5px;
301
+ line-height: 1.6;
302
+ tab-size: 2;
303
+ white-space: pre;
304
+ overflow-wrap: normal;
305
+ }
306
+ .refino-panel__source:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
307
+ .refino-panel__fields {
308
+ display: grid;
309
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
310
+ gap: 10px 14px;
311
+ }
312
+ .refino-panel__field { display: flex; flex-direction: column; gap: 4px; }
313
+ .refino-panel__label {
314
+ display: flex;
315
+ align-items: baseline;
316
+ gap: 6px;
317
+ color: #d1d5db;
318
+ font-size: 12px;
319
+ }
320
+ .refino-panel__key {
321
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
322
+ font-size: 11px;
323
+ color: #6b7280;
324
+ }
325
+ .refino-panel__field[data-refino-state="dirty"] .refino-panel__label { color: #fbbf24; }
326
+ .refino-panel__input {
327
+ appearance: none;
328
+ width: 100%;
329
+ padding: 8px 10px;
330
+ background: #0b1220;
331
+ color: #e5e7eb;
332
+ border: 1px solid rgba(255, 255, 255, 0.14);
333
+ border-radius: 8px;
334
+ }
335
+ .refino-panel__input--multiline { min-height: 72px; resize: vertical; line-height: 1.5; }
336
+ .refino-panel__input:disabled { opacity: 0.55; cursor: not-allowed; }
337
+ .refino-panel__input:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
338
+ .refino-panel__field[data-refino-state="dirty"] .refino-panel__input { border-color: rgba(251, 191, 36, 0.7); }
339
+ .refino-panel__reason { color: #9ca3af; font-size: 11px; }
340
+ .refino-panel__actions {
341
+ display: flex;
342
+ align-items: center;
343
+ justify-content: space-between;
344
+ gap: 10px;
345
+ flex-wrap: wrap;
346
+ }
347
+ .refino-panel__state { color: #9ca3af; font-size: 12px; }
348
+ @media (max-width: 520px) {
349
+ .refino-panel { right: 8px; left: 8px; width: auto; bottom: 64px; max-height: 76vh; }
350
+ }
190
351
  `;
@@ -21,9 +21,18 @@ export type EditorEventName =
21
21
  | "edit_saved"
22
22
  /** A draft change was thrown away. */
23
23
  | "edit_reverted";
24
+ /**
25
+ * Which editing surface an edit happened on. Three values, forever: it
26
+ * answers "is anyone using article editing", and nothing finer. It is not a
27
+ * page, not a file and not an id.
28
+ */
29
+ export type EditorSurface = "inline_copy" | "article_metadata" | "article_body";
24
30
  export interface EditorEventProperties {
25
31
  editor_opened: Record<string, never>;
26
- edit_started: Record<string, never>;
32
+ /** Which surface the owner started typing on. An enum of three, never the id. */
33
+ edit_started: {
34
+ readonly edit_surface: EditorSurface;
35
+ };
27
36
  /** How many ids were in the save. A count, never the ids. */
28
37
  edit_saved: {
29
38
  readonly changed_count: number;
@@ -1,5 +1,5 @@
1
1
  import type { ContentId } from "@getrefino/core";
2
- import type { CopyEditorState } from "./store.js";
2
+ import type { ArticleState, CopyEditorState, EditorPanel } from "./store.js";
3
3
  import type { IntegrationUpdateSource } from "./updates.js";
4
4
  export interface CopyEditor {
5
5
  readonly state: CopyEditorState;
@@ -9,6 +9,10 @@ export interface CopyEditor {
9
9
  readonly signInHref: string;
10
10
  /** The provider's update source, if the host offers integration updates. */
11
11
  readonly updates: IntegrationUpdateSource | undefined;
12
+ /** Articles on this page, in the order the host declared them. Empty on a page without one. */
13
+ readonly articles: readonly ArticleState[];
14
+ openPanel(panel: EditorPanel): void;
15
+ closePanel(): void;
12
16
  save(): Promise<void>;
13
17
  revertValue(id: ContentId): void;
14
18
  revertAll(): void;
@@ -10,6 +10,7 @@ export function useCopyEditor() {
10
10
  const { store, onExit, signInHref, updates } = useCopyStoreOrThrow();
11
11
  const state = useCopyEditorState(store);
12
12
  const dirtyIds = useMemo(() => getDirtyIds(state.draft), [state.draft]);
13
+ const articles = useMemo(() => state.documentIds.map((id) => state.documents[id]).filter((article) => article !== undefined), [state.documentIds, state.documents]);
13
14
  const exit = useCallback(async () => {
14
15
  const count = getDirtyIds(store.getState().draft).length;
15
16
  if (count > 0 && typeof window !== "undefined") {
@@ -25,11 +26,14 @@ export function useCopyEditor() {
25
26
  isDirty: dirtyIds.length > 0,
26
27
  signInHref,
27
28
  updates,
29
+ articles,
30
+ openPanel: store.openPanel,
31
+ closePanel: store.closePanel,
28
32
  save: store.save,
29
33
  revertValue: store.revertValue,
30
34
  revertAll: store.revertAll,
31
35
  reloadLatest: store.reloadLatest,
32
36
  dismissError: store.dismissError,
33
37
  exit,
34
- }), [state, dirtyIds, store, exit, signInHref, updates]);
38
+ }), [state, dirtyIds, store, exit, signInHref, updates, articles]);
35
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrefino/react",
3
- "version": "0.1.0-rc.5",
3
+ "version": "0.1.0",
4
4
  "description": "React components for Refino: edit a website's existing copy in place on the rendered page and commit it to the repository. Click text. Edit text. Save.",
5
5
  "keywords": [
6
6
  "refino",
@@ -37,7 +37,7 @@
37
37
  "./package.json": "./package.json"
38
38
  },
39
39
  "dependencies": {
40
- "@getrefino/core": "0.1.0-rc.5"
40
+ "@getrefino/core": "0.1.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "react": "^18.2.0 || ^19.0.0",