@getrefino/react 0.1.0-rc.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/EditableText.js +44 -3
- package/dist/Toolbar.js +10 -2
- package/dist/store.d.ts +9 -0
- package/dist/store.js +11 -1
- package/package.json +2 -2
package/dist/EditableText.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/Toolbar.js
CHANGED
|
@@ -24,6 +24,14 @@ export function CopyEditorToolbar() {
|
|
|
24
24
|
const busy = state.status === "saving" || state.status === "loading";
|
|
25
25
|
const update = useIntegrationUpdate(editor.updates, state.loaded);
|
|
26
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;
|
|
27
35
|
const onRevertSelected = useCallback(() => {
|
|
28
36
|
if (state.activeId)
|
|
29
37
|
editor.revertValue(state.activeId);
|
|
@@ -45,7 +53,7 @@ export function CopyEditorToolbar() {
|
|
|
45
53
|
dotClass = "saved";
|
|
46
54
|
break;
|
|
47
55
|
case "conflict":
|
|
48
|
-
statusText = "Repository changed";
|
|
56
|
+
statusText = conflictArticle ? "Article changed" : "Repository changed";
|
|
49
57
|
dotClass = "conflict";
|
|
50
58
|
break;
|
|
51
59
|
case "error":
|
|
@@ -56,7 +64,7 @@ export function CopyEditorToolbar() {
|
|
|
56
64
|
statusText = dirtyCount > 0 ? `${plural(dirtyCount, "unsaved change")}` : "No changes";
|
|
57
65
|
dotClass = dirtyCount > 0 ? "dirty" : "";
|
|
58
66
|
}
|
|
59
|
-
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: [
|
|
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"
|
|
60
68
|
? editor.closePanel()
|
|
61
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" })] })] }));
|
|
62
70
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -4,6 +4,15 @@ export type EditorStatus = "idle" | "loading" | "saving" | "saved" | "error" | "
|
|
|
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;
|
package/dist/store.js
CHANGED
|
@@ -360,7 +360,17 @@ export function createCopyStore(options) {
|
|
|
360
360
|
return;
|
|
361
361
|
}
|
|
362
362
|
if (!body.ok && body.error.code === "CONFLICT") {
|
|
363
|
-
|
|
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
|
+
});
|
|
364
374
|
return;
|
|
365
375
|
}
|
|
366
376
|
if (!body.ok) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getrefino/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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.
|
|
40
|
+
"@getrefino/core": "0.2.0"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"react": "^18.2.0 || ^19.0.0",
|