@getrefino/react 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Refino contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @getrefino/react
2
+
3
+ React layer for **Refino** — the site owner edits the website's visible copy in
4
+ place on the rendered page, and the repository stays the source of truth.
5
+
6
+ Installed for you by `npx @getrefino/cli init`.
7
+
8
+ ```tsx
9
+ <RefinoProvider content={copy} editing={isOwner} endpoint="/api/copy" onExit={...}>
10
+ <EditableText id="home.hero.title" as="h1" className="hero__title" />
11
+ </RefinoProvider>
12
+ ```
13
+
14
+ In normal mode `EditableText` renders a plain semantic element — exactly
15
+ `<h1 class="hero__title">…</h1>`, with no editor code path. In edit mode the
16
+ same element becomes editable in place and a small toolbar appears (Save,
17
+ Revert, Revert all, Exit). Use `useCopyEditor()` with `toolbar={false}` for
18
+ custom controls.
19
+
20
+ Editor CSS is scoped to `data-refino-*` attributes and `refino-*` class names,
21
+ so host styles are untouched.
22
+
23
+ <https://refino.dev>
@@ -0,0 +1,22 @@
1
+ import type { AllHTMLAttributes, JSX, ReactNode } from "react";
2
+ export type EditableTextElement = keyof JSX.IntrinsicElements;
3
+ export interface EditableTextProps extends Omit<AllHTMLAttributes<HTMLElement>, "id" | "children" | "as" | "contentEditable" | "dangerouslySetInnerHTML"> {
4
+ /** Stable content ID, e.g. `home.hero.title`. Must exist in the copy file. */
5
+ id: string;
6
+ /** Element to render. Defaults to `span`. The same element is used in both modes. */
7
+ as?: EditableTextElement | undefined;
8
+ /** DOM `id` attribute, since `id` is taken by the content ID. */
9
+ htmlId?: string | undefined;
10
+ /**
11
+ * Fallback rendered when the ID is missing from the copy file. Useful while
12
+ * migrating literal strings; the copy file is still the source of truth.
13
+ */
14
+ children?: ReactNode | undefined;
15
+ }
16
+ /**
17
+ * Renders a plain-text string from the copy file inside a real semantic
18
+ * element. In normal mode it is just that element. In edit mode the same
19
+ * element becomes editable in place, so typography, wrapping and layout are
20
+ * exactly what visitors see.
21
+ */
22
+ export declare function EditableText(props: EditableTextProps): JSX.Element;
@@ -0,0 +1,138 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createElement, useCallback, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
4
+ import { useCopyContext } from "./context.js";
5
+ import { isDevelopment } from "./env.js";
6
+ import { INSTRUCTIONS_ID, escapeHtml, insertTextAtCaret, placeCaretAtEndIfNeeded, readText, supportsPlaintextOnly, toPlainText, } from "./dom.js";
7
+ const warned = new Set();
8
+ function warnMissing(id) {
9
+ if (warned.has(id) || typeof console === "undefined")
10
+ return;
11
+ warned.add(id);
12
+ console.warn(`[refino] No copy found for id "${id}". Add it to your copy file.`);
13
+ }
14
+ /**
15
+ * Renders a plain-text string from the copy file inside a real semantic
16
+ * element. In normal mode it is just that element. In edit mode the same
17
+ * element becomes editable in place, so typography, wrapping and layout are
18
+ * exactly what visitors see.
19
+ */
20
+ export function EditableText(props) {
21
+ const { id, as = "span", htmlId, children, ...rest } = props;
22
+ const context = useCopyContext();
23
+ const store = context?.store ?? null;
24
+ const subscribe = store ? store.subscribe : noopSubscribe;
25
+ const value = useSyncExternalStore(subscribe, () => store?.getValue(id), () => store?.getValue(id));
26
+ const editing = useSyncExternalStore(subscribe, () => store?.getState().editing ?? false, () => store?.getState().editing ?? false);
27
+ if (value === undefined && isDevelopment) {
28
+ warnMissing(id);
29
+ }
30
+ if (!store || !editing || value === undefined) {
31
+ return createElement(as, { ...rest, id: htmlId }, value ?? children ?? id);
32
+ }
33
+ return (_jsx(EditableField, { id: id, as: as, htmlId: htmlId, rest: rest, value: value, store: store }));
34
+ }
35
+ function noopSubscribe() {
36
+ return () => { };
37
+ }
38
+ function EditableField({ id, as, htmlId, rest, value, store }) {
39
+ const ref = useRef(null);
40
+ const startValueRef = useRef(value);
41
+ const [plaintextOnly] = useState(supportsPlaintextOnly);
42
+ const dirty = useSyncExternalStore(store.subscribe, () => store.isDirty(id), () => store.isDirty(id));
43
+ const active = useSyncExternalStore(store.subscribe, () => store.getState().activeId === id, () => false);
44
+ // While the field is active the browser owns the DOM text. Rendering the
45
+ // value captured at focus time keeps React from touching the text node
46
+ // under the caret; after blur, React writes the final draft value back.
47
+ const displayValue = active ? startValueRef.current : value;
48
+ // React 19 diffs dangerouslySetInnerHTML by object identity, not by its
49
+ // __html string: a fresh object on every render would rewrite innerHTML on
50
+ // every keystroke and throw the caret to the start. Keep one object per value.
51
+ const html = useMemo(() => ({ __html: escapeHtml(displayValue) }), [displayValue]);
52
+ // If the draft value changes while the field is active for a reason other
53
+ // than typing (Revert, Reload latest), push it into the DOM by hand.
54
+ useLayoutEffect(() => {
55
+ const element = ref.current;
56
+ if (!active || !element || readText(element) === value)
57
+ return;
58
+ element.textContent = value;
59
+ startValueRef.current = value;
60
+ placeCaretAtEndIfNeeded(element);
61
+ }, [active, value]);
62
+ const onFocus = useCallback((event) => {
63
+ rest.onFocus?.(event);
64
+ startValueRef.current = store.getValue(id) ?? "";
65
+ store.setActive(id);
66
+ const element = event.currentTarget;
67
+ if (typeof requestAnimationFrame === "function") {
68
+ requestAnimationFrame(() => {
69
+ if (element.ownerDocument.activeElement === element)
70
+ placeCaretAtEndIfNeeded(element);
71
+ });
72
+ }
73
+ }, [id, rest, store]);
74
+ const onBlur = useCallback((event) => {
75
+ rest.onBlur?.(event);
76
+ store.setValue(id, readText(event.currentTarget));
77
+ store.setActive(null);
78
+ }, [id, rest, store]);
79
+ const onInput = useCallback((event) => {
80
+ rest.onInput?.(event);
81
+ store.setValue(id, readText(event.currentTarget));
82
+ }, [id, rest, store]);
83
+ const onKeyDown = useCallback((event) => {
84
+ rest.onKeyDown?.(event);
85
+ if (event.defaultPrevented)
86
+ return;
87
+ const element = event.currentTarget;
88
+ if (event.key === "Enter") {
89
+ event.preventDefault();
90
+ element.blur();
91
+ }
92
+ else if (event.key === "Escape") {
93
+ event.preventDefault();
94
+ element.textContent = startValueRef.current;
95
+ store.setValue(id, startValueRef.current);
96
+ element.blur();
97
+ }
98
+ }, [id, rest, store]);
99
+ const onPaste = useCallback((event) => {
100
+ rest.onPaste?.(event);
101
+ if (event.defaultPrevented)
102
+ return;
103
+ event.preventDefault();
104
+ const text = toPlainText(event.clipboardData.getData("text/plain"));
105
+ if (text)
106
+ insertTextAtCaret(event.currentTarget, text);
107
+ }, [rest]);
108
+ const preventDefault = useCallback((event) => {
109
+ event.preventDefault();
110
+ }, []);
111
+ const onClick = useCallback((event) => {
112
+ rest.onClick?.(event);
113
+ // Links and buttons must not navigate or submit while editing words.
114
+ event.preventDefault();
115
+ }, [rest]);
116
+ const describedBy = [rest["aria-describedby"], INSTRUCTIONS_ID].filter(Boolean).join(" ");
117
+ return createElement(as, {
118
+ ...rest,
119
+ ref,
120
+ id: htmlId,
121
+ contentEditable: plaintextOnly ? "plaintext-only" : true,
122
+ suppressContentEditableWarning: true,
123
+ tabIndex: rest.tabIndex ?? 0,
124
+ draggable: false,
125
+ "aria-describedby": describedBy,
126
+ "data-refino-id": id,
127
+ "data-refino-state": active ? "active" : dirty ? "dirty" : "idle",
128
+ dangerouslySetInnerHTML: html,
129
+ onFocus,
130
+ onBlur,
131
+ onInput,
132
+ onKeyDown,
133
+ onPaste,
134
+ onClick,
135
+ onDragStart: preventDefault,
136
+ onDrop: preventDefault,
137
+ });
138
+ }
@@ -0,0 +1,36 @@
1
+ import type { CopyContent } from "@getrefino/core";
2
+ import type { JSX, ReactNode } from "react";
3
+ import type { CopyRequestHeaders } from "./store.js";
4
+ export interface RefinoProviderProps {
5
+ /** The canonical copy, usually `import copy from "./content/copy.json"`. */
6
+ content: CopyContent;
7
+ /**
8
+ * Turn on edit mode. The host decides this server-side from its own
9
+ * authentication; this prop is a rendering switch, not a security boundary.
10
+ */
11
+ editing?: boolean | undefined;
12
+ /** Authenticated endpoint that wraps `createContentApi`. GET loads, POST saves. */
13
+ endpoint?: string | undefined;
14
+ /**
15
+ * Extra request headers for the endpoint, or a function returning them.
16
+ * Hosted mode passes the Refino editor token as `authorization`.
17
+ */
18
+ headers?: CopyRequestHeaders | undefined;
19
+ /**
20
+ * Where the toolbar sends the owner when the endpoint answers 401
21
+ * (session ended). Defaults to `/edit`, the login page in both modes.
22
+ */
23
+ signInHref?: string | undefined;
24
+ /** Called when the user presses Exit (after confirming unsaved changes). */
25
+ onExit?: (() => void | Promise<void>) | undefined;
26
+ /** Render the built-in toolbar in edit mode. Set false to use `useCopyEditor` with custom UI. */
27
+ toolbar?: boolean | undefined;
28
+ children?: ReactNode | undefined;
29
+ }
30
+ /**
31
+ * Provides copy to every `EditableText` below it. In normal mode this is a
32
+ * static lookup with no editor code paths. In edit mode it loads the latest
33
+ * canonical copy from `endpoint`, tracks a client-side draft, and renders
34
+ * the toolbar and editor styles.
35
+ */
36
+ export declare function RefinoProvider(props: RefinoProviderProps): JSX.Element;
@@ -0,0 +1,53 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { getDirtyIds } from "@getrefino/core";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import { CopyContext } from "./context.js";
6
+ import { createCopyStore } from "./store.js";
7
+ import { EDITOR_STYLES } from "./styles.js";
8
+ import { CopyEditorToolbar } from "./Toolbar.js";
9
+ /**
10
+ * Provides copy to every `EditableText` below it. In normal mode this is a
11
+ * static lookup with no editor code paths. In edit mode it loads the latest
12
+ * canonical copy from `endpoint`, tracks a client-side draft, and renders
13
+ * the toolbar and editor styles.
14
+ */
15
+ export function RefinoProvider(props) {
16
+ const { content, editing = false, endpoint = "/api/copy", headers, signInHref = "/edit", onExit, toolbar = true, children } = props;
17
+ const [store] = useState(() => createCopyStore({ content, editing, endpoint, headers }));
18
+ useEffect(() => {
19
+ store.setEditing(editing);
20
+ }, [editing, store]);
21
+ useEffect(() => {
22
+ store.setBaseContent(content);
23
+ }, [content, store]);
24
+ useEffect(() => {
25
+ if (!editing)
26
+ return;
27
+ void store.load();
28
+ }, [editing, store]);
29
+ useEffect(() => {
30
+ if (!editing || typeof window === "undefined")
31
+ return;
32
+ const onBeforeUnload = (event) => {
33
+ if (getDirtyIds(store.getState().draft).length === 0)
34
+ return;
35
+ event.preventDefault();
36
+ event.returnValue = "";
37
+ };
38
+ const onKeyDown = (event) => {
39
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
40
+ event.preventDefault();
41
+ void store.save();
42
+ }
43
+ };
44
+ window.addEventListener("beforeunload", onBeforeUnload);
45
+ window.addEventListener("keydown", onKeyDown);
46
+ return () => {
47
+ window.removeEventListener("beforeunload", onBeforeUnload);
48
+ window.removeEventListener("keydown", onKeyDown);
49
+ };
50
+ }, [editing, store]);
51
+ const value = useMemo(() => ({ store, onExit, signInHref }), [store, onExit, signInHref]);
52
+ return (_jsxs(CopyContext.Provider, { value: value, children: [children, editing && (_jsxs(_Fragment, { children: [_jsx("style", { "data-refino-styles": "", children: EDITOR_STYLES }), toolbar && _jsx(CopyEditorToolbar, {})] }))] }));
53
+ }
@@ -0,0 +1,8 @@
1
+ import type { JSX } from "react";
2
+ /**
3
+ * The only global editor chrome: a small fixed pill with Save, Revert, Exit
4
+ * and status. Rendered automatically by `RefinoProvider` in edit mode.
5
+ * Hosts that want their own controls can pass `toolbar={false}` and build
6
+ * on `useCopyEditor()`.
7
+ */
8
+ export declare function CopyEditorToolbar(): JSX.Element;
@@ -0,0 +1,54 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback } from "react";
4
+ import { INSTRUCTIONS_ID } from "./dom.js";
5
+ import { useCopyEditor } from "./useCopyEditor.js";
6
+ function plural(count, word) {
7
+ return `${count} ${word}${count === 1 ? "" : "s"}`;
8
+ }
9
+ /**
10
+ * The only global editor chrome: a small fixed pill with Save, Revert, Exit
11
+ * and status. Rendered automatically by `RefinoProvider` in edit mode.
12
+ * Hosts that want their own controls can pass `toolbar={false}` and build
13
+ * on `useCopyEditor()`.
14
+ */
15
+ export function CopyEditorToolbar() {
16
+ const editor = useCopyEditor();
17
+ const { state, dirtyIds } = editor;
18
+ const dirtyCount = dirtyIds.length;
19
+ const activeDirty = state.activeId !== null && dirtyIds.includes(state.activeId);
20
+ const busy = state.status === "saving" || state.status === "loading";
21
+ const onRevertSelected = useCallback(() => {
22
+ if (state.activeId)
23
+ editor.revertValue(state.activeId);
24
+ }, [editor, state.activeId]);
25
+ let statusText;
26
+ let dotClass;
27
+ switch (state.status) {
28
+ case "loading":
29
+ statusText = "Loading latest copy…";
30
+ dotClass = "saving";
31
+ break;
32
+ case "saving":
33
+ statusText = "Saving…";
34
+ dotClass = "saving";
35
+ break;
36
+ case "saved":
37
+ statusText =
38
+ state.lastSave?.status === "unchanged" ? "Nothing to save" : "Saved";
39
+ dotClass = "saved";
40
+ break;
41
+ case "conflict":
42
+ statusText = "Repository changed";
43
+ dotClass = "conflict";
44
+ break;
45
+ case "error":
46
+ statusText = "Save failed";
47
+ dotClass = "error";
48
+ break;
49
+ default:
50
+ statusText = dirtyCount > 0 ? `${plural(dirtyCount, "unsaved change")}` : "No changes";
51
+ dotClass = dirtyCount > 0 ? "dirty" : "";
52
+ }
53
+ 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." }), 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("button", { type: "button", className: "refino-toolbar__button", onClick: () => void editor.exit(), disabled: busy, children: "Exit" })] })] }));
54
+ }
@@ -0,0 +1,12 @@
1
+ import type { CopyEditorState, CopyStore } from "./store.js";
2
+ export interface CopyContextValue {
3
+ readonly store: CopyStore;
4
+ readonly onExit: (() => void | Promise<void>) | undefined;
5
+ /** Where to send the owner when the session has ended. */
6
+ readonly signInHref: string;
7
+ }
8
+ export declare const CopyContext: import("react").Context<CopyContextValue | null>;
9
+ export declare function useCopyContext(): CopyContextValue | null;
10
+ export declare function useCopyStoreOrThrow(): CopyContextValue;
11
+ /** Subscribe to the whole editor state (toolbar-level components). */
12
+ export declare function useCopyEditorState(store: CopyStore): CopyEditorState;
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { createContext, useContext, useSyncExternalStore } from "react";
3
+ export const CopyContext = createContext(null);
4
+ export function useCopyContext() {
5
+ return useContext(CopyContext);
6
+ }
7
+ export function useCopyStoreOrThrow() {
8
+ const value = useContext(CopyContext);
9
+ if (!value) {
10
+ throw new Error("Wrap your app in <RefinoProvider> before using the copy editor.");
11
+ }
12
+ return value;
13
+ }
14
+ /** Subscribe to the whole editor state (toolbar-level components). */
15
+ export function useCopyEditorState(store) {
16
+ return useSyncExternalStore(store.subscribe, store.getState, store.getState);
17
+ }
package/dist/dom.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** Small DOM helpers for the contentEditable field. Browser-only. */
2
+ export declare const INSTRUCTIONS_ID = "refino-instructions";
3
+ /** Read the plain text the user typed. Browsers insert non-breaking spaces for trailing spaces. */
4
+ export declare function readText(element: HTMLElement): string;
5
+ export declare function escapeHtml(value: string): string;
6
+ /** Plain text only: collapse pasted line breaks and runs of whitespace. */
7
+ export declare function toPlainText(value: string): string;
8
+ /** Put the caret at the end unless the user already clicked somewhere inside. */
9
+ export declare function placeCaretAtEndIfNeeded(element: HTMLElement): void;
10
+ /** Insert text at the caret, replacing any selection. Fires an input event. */
11
+ export declare function insertTextAtCaret(element: HTMLElement, text: string): void;
12
+ /** `contenteditable="plaintext-only"` keeps rich formatting out; fall back to `true` where unsupported. */
13
+ export declare function supportsPlaintextOnly(): boolean;
package/dist/dom.js ADDED
@@ -0,0 +1,72 @@
1
+ /** Small DOM helpers for the contentEditable field. Browser-only. */
2
+ export const INSTRUCTIONS_ID = "refino-instructions";
3
+ /** Read the plain text the user typed. Browsers insert non-breaking spaces for trailing spaces. */
4
+ export function readText(element) {
5
+ return (element.textContent ?? "").replace(/\u00a0/g, " ");
6
+ }
7
+ export function escapeHtml(value) {
8
+ return value
9
+ .replace(/&/g, "&amp;")
10
+ .replace(/</g, "&lt;")
11
+ .replace(/>/g, "&gt;")
12
+ .replace(/"/g, "&quot;")
13
+ .replace(/'/g, "&#39;");
14
+ }
15
+ /** Plain text only: collapse pasted line breaks and runs of whitespace. */
16
+ export function toPlainText(value) {
17
+ return value.replace(/\s+/g, " ");
18
+ }
19
+ function selectionIsInside(element) {
20
+ const selection = element.ownerDocument.getSelection();
21
+ if (!selection || selection.rangeCount === 0)
22
+ return false;
23
+ return element.contains(selection.anchorNode);
24
+ }
25
+ /** Put the caret at the end unless the user already clicked somewhere inside. */
26
+ export function placeCaretAtEndIfNeeded(element) {
27
+ if (selectionIsInside(element))
28
+ return;
29
+ const doc = element.ownerDocument;
30
+ const selection = doc.getSelection();
31
+ if (!selection)
32
+ return;
33
+ const range = doc.createRange();
34
+ range.selectNodeContents(element);
35
+ range.collapse(false);
36
+ selection.removeAllRanges();
37
+ selection.addRange(range);
38
+ }
39
+ /** Insert text at the caret, replacing any selection. Fires an input event. */
40
+ export function insertTextAtCaret(element, text) {
41
+ const doc = element.ownerDocument;
42
+ if (typeof doc.execCommand === "function" && doc.execCommand("insertText", false, text)) {
43
+ return;
44
+ }
45
+ const selection = doc.getSelection();
46
+ if (!selection || selection.rangeCount === 0 || !selectionIsInside(element)) {
47
+ element.textContent = readText(element) + text;
48
+ }
49
+ else {
50
+ const range = selection.getRangeAt(0);
51
+ range.deleteContents();
52
+ const node = doc.createTextNode(text);
53
+ range.insertNode(node);
54
+ range.setStartAfter(node);
55
+ range.collapse(true);
56
+ selection.removeAllRanges();
57
+ selection.addRange(range);
58
+ }
59
+ element.dispatchEvent(new Event("input", { bubbles: true }));
60
+ }
61
+ let plaintextOnlySupport = null;
62
+ /** `contenteditable="plaintext-only"` keeps rich formatting out; fall back to `true` where unsupported. */
63
+ export function supportsPlaintextOnly() {
64
+ if (plaintextOnlySupport !== null)
65
+ return plaintextOnlySupport;
66
+ if (typeof document === "undefined")
67
+ return true;
68
+ const probe = document.createElement("div");
69
+ probe.contentEditable = "plaintext-only";
70
+ plaintextOnlySupport = probe.contentEditable === "plaintext-only";
71
+ return plaintextOnlySupport;
72
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare const isDevelopment: boolean;
package/dist/env.js ADDED
@@ -0,0 +1 @@
1
+ export const isDevelopment = typeof process !== "undefined" && process?.env?.NODE_ENV !== "production";
@@ -0,0 +1,10 @@
1
+ export { RefinoProvider } from "./RefinoProvider.js";
2
+ export type { RefinoProviderProps } from "./RefinoProvider.js";
3
+ export { EditableText } from "./EditableText.js";
4
+ export type { EditableTextElement, EditableTextProps } from "./EditableText.js";
5
+ export { CopyEditorToolbar } from "./Toolbar.js";
6
+ export { useCopyEditor } from "./useCopyEditor.js";
7
+ export type { CopyEditor } from "./useCopyEditor.js";
8
+ export { createCopyStore } from "./store.js";
9
+ export type { CopyEditorState, CopyRequestHeaders, CopyStore, CopyStoreOptions, EditorError, EditorStatus, LastSave, RebaseInfo, } from "./store.js";
10
+ export { EDITOR_STYLES } from "./styles.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { RefinoProvider } from "./RefinoProvider.js";
2
+ export { EditableText } from "./EditableText.js";
3
+ export { CopyEditorToolbar } from "./Toolbar.js";
4
+ export { useCopyEditor } from "./useCopyEditor.js";
5
+ export { createCopyStore } from "./store.js";
6
+ export { EDITOR_STYLES } from "./styles.js";
@@ -0,0 +1,66 @@
1
+ import type { ContentId, ContentSnapshot, CopyContent, DraftState } from "@getrefino/core";
2
+ export type EditorStatus = "idle" | "loading" | "saving" | "saved" | "error" | "conflict";
3
+ export interface EditorError {
4
+ readonly code: string;
5
+ readonly message: string;
6
+ }
7
+ export interface LastSave {
8
+ readonly at: number;
9
+ readonly status: "saved" | "unchanged";
10
+ readonly commitUrl?: string;
11
+ }
12
+ export interface RebaseInfo {
13
+ readonly overlapping: readonly ContentId[];
14
+ readonly dropped: readonly ContentId[];
15
+ }
16
+ /**
17
+ * Everything the editor UI needs. Plain data, replaced immutably on every
18
+ * change so React's `useSyncExternalStore` can subscribe to slices of it.
19
+ */
20
+ export interface CopyEditorState {
21
+ readonly editing: boolean;
22
+ readonly draft: DraftState;
23
+ /** Revision the draft is based on; null until the canonical copy has loaded. */
24
+ readonly revision: string | null;
25
+ readonly loaded: boolean;
26
+ readonly status: EditorStatus;
27
+ readonly error: EditorError | null;
28
+ /** Latest upstream snapshot when a save was refused because the file moved. */
29
+ readonly conflict: ContentSnapshot | null;
30
+ readonly activeId: ContentId | null;
31
+ readonly lastSave: LastSave | null;
32
+ readonly rebase: RebaseInfo | null;
33
+ }
34
+ export type CopyRequestHeaders = Readonly<Record<string, string>> | (() => Readonly<Record<string, string>>);
35
+ export interface CopyStoreOptions {
36
+ readonly content: CopyContent;
37
+ readonly editing: boolean;
38
+ /** Authenticated endpoint: GET loads the snapshot, POST saves changes. */
39
+ readonly endpoint: string;
40
+ /**
41
+ * Extra request headers, or a function returning them (read on every
42
+ * request). Hosted sites pass `{ authorization: "Bearer …" }` for a
43
+ * cross-origin Refino endpoint. Cookies are never sent cross-origin.
44
+ */
45
+ readonly headers?: CopyRequestHeaders | undefined;
46
+ readonly fetch?: typeof fetch;
47
+ }
48
+ export interface CopyStore {
49
+ getState(): CopyEditorState;
50
+ subscribe(listener: () => void): () => void;
51
+ getValue(id: ContentId): string | undefined;
52
+ isDirty(id: ContentId): boolean;
53
+ setValue(id: ContentId, value: string): void;
54
+ revertValue(id: ContentId): void;
55
+ revertAll(): void;
56
+ setActive(id: ContentId | null): void;
57
+ /** Replace base content in normal mode (host re-rendered with new props). */
58
+ setBaseContent(content: CopyContent): void;
59
+ setEditing(editing: boolean): void;
60
+ load(): Promise<void>;
61
+ save(): Promise<void>;
62
+ /** After a conflict: move the draft onto the latest upstream copy. */
63
+ reloadLatest(): Promise<void>;
64
+ dismissError(): void;
65
+ }
66
+ export declare function createCopyStore(options: CopyStoreOptions): CopyStore;
package/dist/store.js ADDED
@@ -0,0 +1,192 @@
1
+ import { createDraft, getDirtyIds, getDraftValue, hasCopy, isDraftValueDirty, rebaseDraft, revertAllDraft, revertDraftValue, setDraftValue, } from "@getrefino/core";
2
+ export function createCopyStore(options) {
3
+ const doFetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
4
+ const listeners = new Set();
5
+ let state = {
6
+ editing: options.editing,
7
+ draft: createDraft(options.content),
8
+ revision: null,
9
+ loaded: false,
10
+ status: "idle",
11
+ error: null,
12
+ conflict: null,
13
+ activeId: null,
14
+ lastSave: null,
15
+ rebase: null,
16
+ };
17
+ function update(patch) {
18
+ state = { ...state, ...patch };
19
+ for (const listener of listeners)
20
+ listener();
21
+ }
22
+ function extraHeaders() {
23
+ const headers = options.headers;
24
+ return typeof headers === "function" ? headers() : (headers ?? {});
25
+ }
26
+ async function request(init) {
27
+ let response;
28
+ try {
29
+ response = await doFetch(options.endpoint, {
30
+ credentials: "same-origin",
31
+ ...init,
32
+ headers: { accept: "application/json", ...extraHeaders(), ...(init?.headers ?? {}) },
33
+ });
34
+ }
35
+ catch (error) {
36
+ return {
37
+ ok: false,
38
+ error: { code: "NETWORK", message: `Could not reach ${options.endpoint}: ${String(error)}` },
39
+ };
40
+ }
41
+ let body;
42
+ try {
43
+ body = await response.json();
44
+ }
45
+ catch {
46
+ body = null;
47
+ }
48
+ if (body && typeof body === "object" && "ok" in body) {
49
+ return body;
50
+ }
51
+ const code = response.status === 401 || response.status === 403 ? "UNAUTHORIZED" : "HTTP_ERROR";
52
+ return {
53
+ ok: false,
54
+ error: {
55
+ code,
56
+ message: code === "UNAUTHORIZED" ? "Your editing session has ended. Sign in again to keep editing." : `Copy endpoint responded with HTTP ${response.status}.`,
57
+ },
58
+ };
59
+ }
60
+ function applySnapshot(snapshot, keepChanges) {
61
+ if (keepChanges) {
62
+ const result = rebaseDraft(state.draft, snapshot.content);
63
+ update({
64
+ draft: result.draft,
65
+ revision: snapshot.revision,
66
+ loaded: true,
67
+ rebase: result.overlapping.length > 0 || result.dropped.length > 0
68
+ ? { overlapping: result.overlapping, dropped: result.dropped }
69
+ : null,
70
+ });
71
+ }
72
+ else {
73
+ update({ draft: createDraft(snapshot.content), revision: snapshot.revision, loaded: true, rebase: null });
74
+ }
75
+ }
76
+ return {
77
+ getState: () => state,
78
+ subscribe(listener) {
79
+ listeners.add(listener);
80
+ return () => {
81
+ listeners.delete(listener);
82
+ };
83
+ },
84
+ getValue: (id) => getDraftValue(state.draft, id),
85
+ isDirty: (id) => isDraftValueDirty(state.draft, id),
86
+ setValue(id, value) {
87
+ if (!hasCopy(state.draft.base, id))
88
+ return;
89
+ const draft = setDraftValue(state.draft, id, value);
90
+ if (draft === state.draft)
91
+ return;
92
+ update({
93
+ draft,
94
+ status: state.status === "saved" || state.status === "error" ? "idle" : state.status,
95
+ error: state.status === "error" ? null : state.error,
96
+ });
97
+ },
98
+ revertValue(id) {
99
+ const draft = revertDraftValue(state.draft, id);
100
+ if (draft !== state.draft)
101
+ update({ draft });
102
+ },
103
+ revertAll() {
104
+ update({ draft: revertAllDraft(state.draft), rebase: null });
105
+ },
106
+ setActive(id) {
107
+ if (state.activeId !== id)
108
+ update({ activeId: id });
109
+ },
110
+ setBaseContent(content) {
111
+ if (state.editing)
112
+ return;
113
+ update({ draft: createDraft(content) });
114
+ },
115
+ setEditing(editing) {
116
+ if (state.editing !== editing)
117
+ update({ editing });
118
+ },
119
+ async load() {
120
+ update({ status: "loading", error: null });
121
+ const body = await request({ method: "GET" });
122
+ if (body.ok && "snapshot" in body) {
123
+ applySnapshot(body.snapshot, getDirtyIds(state.draft).length > 0);
124
+ update({ status: "idle" });
125
+ }
126
+ else if (!body.ok) {
127
+ update({ status: "error", error: body.error });
128
+ }
129
+ },
130
+ async save() {
131
+ if (state.status === "saving")
132
+ return;
133
+ if (state.revision === null) {
134
+ update({
135
+ status: "error",
136
+ error: { code: "NOT_LOADED", message: "The latest copy has not loaded yet. Try again." },
137
+ });
138
+ return;
139
+ }
140
+ const changes = state.draft.changes;
141
+ if (Object.keys(changes).length === 0) {
142
+ update({ status: "saved", lastSave: { at: Date.now(), status: "unchanged" }, error: null });
143
+ return;
144
+ }
145
+ update({ status: "saving", error: null, conflict: null });
146
+ const body = await request({
147
+ method: "POST",
148
+ headers: { "content-type": "application/json" },
149
+ body: JSON.stringify({ baseRevision: state.revision, changes }),
150
+ });
151
+ if (body.ok && "result" in body) {
152
+ const { result } = body;
153
+ applySnapshot(result.snapshot, false);
154
+ const lastSave = result.status === "saved" && result.commit?.url
155
+ ? { at: Date.now(), status: result.status, commitUrl: result.commit.url }
156
+ : { at: Date.now(), status: result.status };
157
+ update({ status: "saved", lastSave });
158
+ return;
159
+ }
160
+ if (!body.ok && body.error.code === "CONFLICT") {
161
+ update({ status: "conflict", conflict: body.error.current ?? null, error: body.error });
162
+ return;
163
+ }
164
+ if (!body.ok) {
165
+ update({ status: "error", error: body.error });
166
+ return;
167
+ }
168
+ update({ status: "error", error: { code: "UNEXPECTED", message: "Unexpected response from copy endpoint." } });
169
+ },
170
+ async reloadLatest() {
171
+ const known = state.conflict;
172
+ if (known) {
173
+ applySnapshot(known, true);
174
+ update({ status: "idle", conflict: null, error: null });
175
+ return;
176
+ }
177
+ update({ status: "loading", error: null });
178
+ const body = await request({ method: "GET" });
179
+ if (body.ok && "snapshot" in body) {
180
+ applySnapshot(body.snapshot, true);
181
+ update({ status: "idle", conflict: null });
182
+ }
183
+ else if (!body.ok) {
184
+ update({ status: "error", error: body.error });
185
+ }
186
+ },
187
+ dismissError() {
188
+ if (state.status === "error")
189
+ update({ status: "idle", error: null });
190
+ },
191
+ };
192
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Editor-only CSS. Every selector is scoped to `data-refino-*`
3
+ * attributes or `refino-` class names so host styles are untouched.
4
+ * `outline` and `box-shadow` never affect layout, so text reflows exactly
5
+ * as it does for visitors.
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__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";
package/dist/styles.js ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Editor-only CSS. Every selector is scoped to `data-refino-*`
3
+ * attributes or `refino-` class names so host styles are untouched.
4
+ * `outline` and `box-shadow` never affect layout, so text reflows exactly
5
+ * as it does for visitors.
6
+ */
7
+ export const EDITOR_STYLES = `
8
+ [data-refino-id] {
9
+ cursor: text;
10
+ outline: 1px dashed transparent;
11
+ outline-offset: 3px;
12
+ border-radius: 2px;
13
+ transition: outline-color 120ms ease, box-shadow 120ms ease;
14
+ }
15
+ [data-refino-id]:hover {
16
+ outline-color: rgba(37, 99, 235, 0.6);
17
+ }
18
+ [data-refino-id][data-refino-state="dirty"] {
19
+ outline-color: rgba(217, 119, 6, 0.75);
20
+ }
21
+ [data-refino-id][data-refino-state="active"],
22
+ [data-refino-id]:focus,
23
+ [data-refino-id]:focus-visible {
24
+ outline: 2px solid #2563eb;
25
+ outline-offset: 3px;
26
+ box-shadow: 0 0 0 5px rgba(37, 99, 235, 0.14);
27
+ }
28
+ [data-refino-id]:empty::before {
29
+ content: "\\00a0";
30
+ }
31
+ .refino-sr-only {
32
+ position: absolute;
33
+ width: 1px;
34
+ height: 1px;
35
+ padding: 0;
36
+ margin: -1px;
37
+ overflow: hidden;
38
+ clip: rect(0, 0, 0, 0);
39
+ white-space: nowrap;
40
+ border: 0;
41
+ }
42
+ .refino-toolbar {
43
+ all: initial;
44
+ position: fixed;
45
+ left: 50%;
46
+ bottom: 16px;
47
+ transform: translateX(-50%);
48
+ z-index: 2147483000;
49
+ display: flex;
50
+ flex-direction: column;
51
+ align-items: center;
52
+ gap: 8px;
53
+ max-width: calc(100vw - 24px);
54
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
55
+ font-size: 13px;
56
+ line-height: 1.3;
57
+ color: #f9fafb;
58
+ box-sizing: border-box;
59
+ }
60
+ .refino-toolbar *, .refino-toolbar *::before, .refino-toolbar *::after {
61
+ box-sizing: border-box;
62
+ font: inherit;
63
+ color: inherit;
64
+ }
65
+ .refino-toolbar__bar {
66
+ display: flex;
67
+ flex-wrap: wrap;
68
+ align-items: center;
69
+ justify-content: center;
70
+ gap: 6px;
71
+ padding: 8px 10px;
72
+ background: #111827;
73
+ border: 1px solid rgba(255, 255, 255, 0.12);
74
+ border-radius: 999px;
75
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
76
+ }
77
+ .refino-toolbar__status {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 6px;
81
+ padding: 0 8px;
82
+ min-width: 0;
83
+ white-space: nowrap;
84
+ color: #d1d5db;
85
+ }
86
+ .refino-toolbar__dot {
87
+ width: 8px;
88
+ height: 8px;
89
+ border-radius: 50%;
90
+ background: #6b7280;
91
+ flex: none;
92
+ }
93
+ .refino-toolbar__dot--dirty { background: #f59e0b; }
94
+ .refino-toolbar__dot--saving { background: #60a5fa; }
95
+ .refino-toolbar__dot--saved { background: #34d399; }
96
+ .refino-toolbar__dot--error, .refino-toolbar__dot--conflict { background: #f87171; }
97
+ .refino-toolbar__key {
98
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
99
+ font-size: 12px;
100
+ color: #9ca3af;
101
+ padding: 0 8px;
102
+ max-width: 220px;
103
+ overflow: hidden;
104
+ text-overflow: ellipsis;
105
+ white-space: nowrap;
106
+ }
107
+ .refino-toolbar__button {
108
+ appearance: none;
109
+ cursor: pointer;
110
+ border: 1px solid transparent;
111
+ border-radius: 999px;
112
+ padding: 6px 12px;
113
+ background: transparent;
114
+ color: #e5e7eb;
115
+ font-weight: 500;
116
+ }
117
+ .refino-toolbar__button:hover:not(:disabled) {
118
+ background: rgba(255, 255, 255, 0.08);
119
+ }
120
+ .refino-toolbar__button:focus-visible {
121
+ outline: 2px solid #93c5fd;
122
+ outline-offset: 1px;
123
+ }
124
+ .refino-toolbar__button:disabled {
125
+ opacity: 0.45;
126
+ cursor: default;
127
+ }
128
+ .refino-toolbar__button--primary {
129
+ background: #2563eb;
130
+ color: #ffffff;
131
+ }
132
+ .refino-toolbar__button--primary:hover:not(:disabled) {
133
+ background: #1d4ed8;
134
+ }
135
+ .refino-toolbar__notice {
136
+ padding: 10px 14px;
137
+ border-radius: 12px;
138
+ background: #1f2937;
139
+ border: 1px solid rgba(255, 255, 255, 0.12);
140
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
141
+ max-width: 560px;
142
+ color: #f3f4f6;
143
+ }
144
+ .refino-toolbar__notice--error { border-color: rgba(248, 113, 113, 0.6); }
145
+ .refino-toolbar__notice--conflict { border-color: rgba(251, 191, 36, 0.6); }
146
+ .refino-toolbar__notice p { margin: 0 0 6px; }
147
+ .refino-toolbar__notice p:last-child { margin: 0; }
148
+ .refino-toolbar__notice code {
149
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
150
+ font-size: 12px;
151
+ }
152
+ .refino-toolbar__link {
153
+ color: #93c5fd;
154
+ text-decoration: underline;
155
+ }
156
+ @media (max-width: 520px) {
157
+ .refino-toolbar { bottom: 8px; }
158
+ .refino-toolbar__key { display: none; }
159
+ }
160
+ `;
@@ -0,0 +1,21 @@
1
+ import type { ContentId } from "@getrefino/core";
2
+ import type { CopyEditorState } from "./store.js";
3
+ export interface CopyEditor {
4
+ readonly state: CopyEditorState;
5
+ readonly dirtyIds: readonly ContentId[];
6
+ readonly isDirty: boolean;
7
+ /** Login page to offer when the endpoint reports the session ended. */
8
+ readonly signInHref: string;
9
+ save(): Promise<void>;
10
+ revertValue(id: ContentId): void;
11
+ revertAll(): void;
12
+ reloadLatest(): Promise<void>;
13
+ dismissError(): void;
14
+ /** Confirms when there are unsaved changes, then calls the provider's `onExit`. */
15
+ exit(): Promise<void>;
16
+ }
17
+ /**
18
+ * Access editor state and actions. Use this to build custom controls when
19
+ * the default toolbar does not fit the host site.
20
+ */
21
+ export declare function useCopyEditor(): CopyEditor;
@@ -0,0 +1,34 @@
1
+ "use client";
2
+ import { getDirtyIds } from "@getrefino/core";
3
+ import { useCallback, useMemo } from "react";
4
+ import { useCopyEditorState, useCopyStoreOrThrow } from "./context.js";
5
+ /**
6
+ * Access editor state and actions. Use this to build custom controls when
7
+ * the default toolbar does not fit the host site.
8
+ */
9
+ export function useCopyEditor() {
10
+ const { store, onExit, signInHref } = useCopyStoreOrThrow();
11
+ const state = useCopyEditorState(store);
12
+ const dirtyIds = useMemo(() => getDirtyIds(state.draft), [state.draft]);
13
+ const exit = useCallback(async () => {
14
+ const count = getDirtyIds(store.getState().draft).length;
15
+ if (count > 0 && typeof window !== "undefined") {
16
+ const ok = window.confirm(`You have ${count} unsaved change${count === 1 ? "" : "s"}. Exit without saving?`);
17
+ if (!ok)
18
+ return;
19
+ }
20
+ await onExit?.();
21
+ }, [onExit, store]);
22
+ return useMemo(() => ({
23
+ state,
24
+ dirtyIds,
25
+ isDirty: dirtyIds.length > 0,
26
+ signInHref,
27
+ save: store.save,
28
+ revertValue: store.revertValue,
29
+ revertAll: store.revertAll,
30
+ reloadLatest: store.reloadLatest,
31
+ dismissError: store.dismissError,
32
+ exit,
33
+ }), [state, dirtyIds, store, exit, signInHref]);
34
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@getrefino/react",
3
+ "version": "0.1.0-rc.1",
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
+ "keywords": [
6
+ "refino",
7
+ "react",
8
+ "copy",
9
+ "content",
10
+ "inline-editing",
11
+ "cms-alternative"
12
+ ],
13
+ "license": "MIT",
14
+ "homepage": "https://refino.dev",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/forant/inline-copy.git",
18
+ "directory": "packages/react"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "provenance": false
23
+ },
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "files": [
27
+ "dist/**/*.js",
28
+ "dist/**/*.d.ts",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
39
+ "dependencies": {
40
+ "@getrefino/core": "0.1.0-rc.1"
41
+ },
42
+ "peerDependencies": {
43
+ "react": "^18.2.0 || ^19.0.0",
44
+ "react-dom": "^18.2.0 || ^19.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@testing-library/dom": "10.4.1",
48
+ "@testing-library/jest-dom": "7.0.1",
49
+ "@testing-library/react": "16.3.3",
50
+ "@testing-library/user-event": "14.6.7",
51
+ "@types/react": "19.3.0",
52
+ "@types/react-dom": "19.3.0",
53
+ "@vitejs/plugin-react": "6.1.1",
54
+ "jsdom": "30.0.1",
55
+ "react": "19.3.0",
56
+ "react-dom": "19.3.0",
57
+ "typescript": "6.0.3",
58
+ "vitest": "5.0.0"
59
+ },
60
+ "scripts": {
61
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
62
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
63
+ "typecheck": "tsc -p tsconfig.json --noEmit",
64
+ "test": "vitest run",
65
+ "clean": "rm -rf dist"
66
+ }
67
+ }