Skip to content

Commit 0fdba20

Browse files
authored
WEB-142 Add keybinds, ctrl+s to save in editor (#128)
2 parents 82b5200 + 123f0a5 commit 0fdba20

4 files changed

Lines changed: 239 additions & 44 deletions

File tree

src/app/editor/[id]/[slug]/SaveButton.tsx

Lines changed: 11 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,22 @@
11
"use client";
22

3-
import { createUsePuck } from "@puckeditor/core";
4-
import { useTransition } from "react";
5-
import { toast } from "sonner";
3+
import { cn } from "@/lib/utils";
64
import { useDocumentContext } from "./document-context";
7-
import { saveVersionAction } from "../../../../lib/documents/actions";
8-
import { getEditorUrl, getPreviewUrl } from "../../../../lib/editor-url";
9-
import { runAction } from "../../runAction";
10-
import { useDialogs } from "@/components/ui/dialog-provider";
11-
12-
const usePuck = createUsePuck();
5+
import { useEditorSaveContext } from "./editor-save-context";
136

147
export function SaveButton() {
15-
const data = usePuck((s) => s.appState.data);
16-
const { documentId, documentName, isArchived, isDirty, addVersion } = useDocumentContext();
17-
const { alert } = useDialogs();
18-
19-
const [isSaving, startTransition] = useTransition();
20-
21-
const handleSave = () => {
22-
startTransition(async () => {
23-
const result = await runAction(saveVersionAction({ documentId, content: data }));
24-
25-
if (result.success === false) {
26-
await alert(result.error);
27-
return;
28-
}
29-
30-
addVersion(result.data.version);
31-
window.history.replaceState(window.history.state, "", getEditorUrl(documentId, documentName, result.data.version.id));
32-
33-
const previewUrl = getPreviewUrl(documentId, documentName, result.data.version.id);
34-
toast.success("Saved", {
35-
action: {
36-
label: "Preview",
37-
38-
onClick: () => {
39-
window.open(previewUrl, "_blank");
40-
},
41-
},
42-
});
43-
});
44-
};
8+
const { isArchived } = useDocumentContext();
9+
const { canSave, isSaving, save } = useEditorSaveContext();
10+
const isDisabled = isSaving || isArchived;
4511

4612
return (
4713
<button
48-
onClick={handleSave}
49-
disabled={isSaving || isArchived || !isDirty}
50-
className="px-3 py-1.5 bg-gray-600 text-white text-sm rounded hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
14+
onClick={save}
15+
disabled={isDisabled}
16+
className={cn(
17+
"px-3 py-1.5 text-white text-sm rounded transition disabled:opacity-50 disabled:cursor-not-allowed",
18+
canSave ? "bg-blue-600 hover:bg-blue-700" : "bg-gray-600 hover:bg-gray-700",
19+
)}
5120
>
5221
{isArchived ? "Archived" : isSaving ? "Saving..." : "Save"}
5322
</button>

src/app/editor/[id]/[slug]/client.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import type { Data } from "@puckeditor/core";
44
import { Puck, blocksPlugin, outlinePlugin } from "@puckeditor/core";
55
import config from "../../../../puck.config";
6-
import { useState, useCallback } from "react";
6+
import { useState, useCallback, type ReactNode } from "react";
77
import { VersionPlugin } from "./VersionPlugin";
88
import { ActionBarOverride } from "./ActionBarOverride";
99
import { SaveButton } from "./SaveButton";
10+
import { EditorSaveProvider, useEditorSaveHotkey } from "./editor-save-context";
1011
import { MediaProvider, type MediaWithUrl } from "@/components/puck/media-context";
1112
import type { Version } from "../../../../lib/types";
1213
import { useUnsavedChangesGuard } from "./useUnsavedChangesGuard";
@@ -62,11 +63,29 @@ export function Client({
6263
}
6364
overrides={{
6465
actionBar: ActionBarOverride,
65-
headerActions: SaveButton
66+
headerActions: SaveButton,
67+
puck: EditorPuckOverride,
68+
iframe: EditorIframeOverride,
6669
}}
6770
/>
6871
</UnsavedChangesContext.Provider>
6972
</DocumentContext.Provider>
7073
</MediaProvider>
7174
);
7275
}
76+
77+
function EditorPuckOverride({ children }: { children: ReactNode }) {
78+
return <EditorSaveProvider>{children}</EditorSaveProvider>;
79+
}
80+
81+
function EditorIframeOverride({
82+
children,
83+
document,
84+
}: {
85+
children: ReactNode;
86+
document?: Document;
87+
}) {
88+
useEditorSaveHotkey(document, Boolean(document));
89+
90+
return <>{children}</>;
91+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"use client";
2+
3+
import { createContext, useCallback, useContext, useRef, useTransition, type ReactNode } from "react";
4+
import { createUsePuck } from "@puckeditor/core";
5+
import { toast } from "sonner";
6+
import { getEditorUrl, getPreviewUrl } from "../../../../lib/editor-url";
7+
import { saveVersionAction } from "../../../../lib/documents/actions";
8+
import { runAction } from "../../runAction";
9+
import { useKeybind } from "@/hooks/useKeybind";
10+
import { useDialogs } from "@/components/ui/dialog-provider";
11+
import { useDocumentContext } from "./document-context";
12+
13+
const usePuck = createUsePuck();
14+
15+
const SAVE_KEYBINDS = [
16+
{ key: "s", ctrlKey: true },
17+
{ key: "s", metaKey: true },
18+
] as const;
19+
20+
type EditorSaveContextType = {
21+
canSave: boolean;
22+
isSaving: boolean;
23+
save: () => void;
24+
};
25+
26+
const EditorSaveContext = createContext<EditorSaveContextType>({
27+
canSave: false,
28+
isSaving: false,
29+
save: () => {},
30+
});
31+
32+
function useSaveHotkey(save: () => void, target?: Document | null, enabled = true) {
33+
useKeybind(
34+
SAVE_KEYBINDS,
35+
(event) => {
36+
event.preventDefault();
37+
save();
38+
},
39+
[target],
40+
{ capture: true, enabled },
41+
);
42+
}
43+
44+
export function EditorSaveProvider({ children }: { children: ReactNode }) {
45+
const data = usePuck((s) => s.appState.data);
46+
const { documentId, documentName, isArchived, isDirty, addVersion } = useDocumentContext();
47+
const { alert } = useDialogs();
48+
const [isSaving, startTransition] = useTransition();
49+
const canSave = !isArchived && !isSaving && isDirty;
50+
const saveStateRef = useRef({
51+
canSave,
52+
isArchived,
53+
isSaving,
54+
isDirty,
55+
data,
56+
documentId,
57+
documentName,
58+
});
59+
60+
saveStateRef.current = {
61+
canSave,
62+
isArchived,
63+
isSaving,
64+
isDirty,
65+
data,
66+
documentId,
67+
documentName,
68+
};
69+
70+
const save = useCallback(() => {
71+
const { canSave, isArchived, isSaving, isDirty, data, documentId, documentName } = saveStateRef.current;
72+
73+
if (!canSave) {
74+
if (!isArchived && !isSaving && !isDirty) {
75+
toast.info("No changes to save", { id: "editor-no-changes-to-save" });
76+
}
77+
78+
return;
79+
}
80+
81+
startTransition(async () => {
82+
const result = await runAction(saveVersionAction({ documentId, content: data }));
83+
84+
if (result.success === false) {
85+
await alert(result.error);
86+
return;
87+
}
88+
89+
addVersion(result.data.version);
90+
window.history.replaceState(window.history.state, "", getEditorUrl(documentId, documentName, result.data.version.id));
91+
92+
const previewUrl = getPreviewUrl(documentId, documentName, result.data.version.id);
93+
toast.success("Saved", {
94+
action: {
95+
label: "Preview",
96+
onClick: () => {
97+
window.open(previewUrl, "_blank");
98+
},
99+
},
100+
});
101+
});
102+
}, [addVersion, alert, startTransition]);
103+
104+
useSaveHotkey(save);
105+
106+
return (
107+
<EditorSaveContext.Provider value={{ canSave, isSaving, save }}>
108+
{children}
109+
</EditorSaveContext.Provider>
110+
);
111+
}
112+
113+
export function useEditorSaveContext() {
114+
return useContext(EditorSaveContext);
115+
}
116+
117+
export function useEditorSaveHotkey(target?: Document | null, enabled = true) {
118+
const { save } = useEditorSaveContext();
119+
120+
useSaveHotkey(save, target, enabled);
121+
}

src/hooks/useKeybind.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"use client";
2+
3+
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
4+
5+
export type Keybind = {
6+
key: string;
7+
altKey?: boolean;
8+
ctrlKey?: boolean;
9+
metaKey?: boolean;
10+
shiftKey?: boolean;
11+
};
12+
13+
type KeybindTarget = Document | HTMLElement | Window | null | undefined;
14+
type KeybindCallback = (event: KeyboardEvent, keybind: Keybind) => void;
15+
16+
function useStableTargetList<T>(values: readonly T[]) {
17+
const ref = useRef(values);
18+
19+
if (ref.current.length !== values.length || ref.current.some((value, index) => value !== values[index])) {
20+
ref.current = values;
21+
}
22+
23+
return ref.current;
24+
}
25+
26+
function matchesKeybind(event: KeyboardEvent, keybind: Keybind) {
27+
return (
28+
event.key.toLowerCase() === keybind.key.toLowerCase() &&
29+
event.altKey === (keybind.altKey ?? false) &&
30+
event.ctrlKey === (keybind.ctrlKey ?? false) &&
31+
event.metaKey === (keybind.metaKey ?? false) &&
32+
event.shiftKey === (keybind.shiftKey ?? false)
33+
);
34+
}
35+
36+
export function useKeybind(
37+
keybinds: readonly Keybind[],
38+
callback: KeybindCallback,
39+
targets: readonly KeybindTarget[] = [null],
40+
{ capture = false, enabled = true }: { capture?: boolean; enabled?: boolean } = {},
41+
) {
42+
const callbackRef = useRef(callback);
43+
const keybindsRef = useRef(keybinds);
44+
const stableTargets = useStableTargetList(targets);
45+
46+
useLayoutEffect(() => {
47+
callbackRef.current = callback;
48+
}, [callback]);
49+
50+
useLayoutEffect(() => {
51+
keybindsRef.current = keybinds;
52+
}, [keybinds]);
53+
54+
const handleKeydown = useCallback((event: KeyboardEvent) => {
55+
const matchedKeybind = keybindsRef.current.find((keybind) => matchesKeybind(event, keybind));
56+
57+
if (!matchedKeybind) {
58+
return;
59+
}
60+
61+
callbackRef.current(event, matchedKeybind);
62+
}, []);
63+
64+
useEffect(() => {
65+
if (!enabled) {
66+
return;
67+
}
68+
69+
const fallbackTarget = typeof document === "undefined" ? null : document;
70+
const resolved = [...new Set(stableTargets.map((node) => node ?? fallbackTarget).filter(Boolean))] as EventTarget[];
71+
72+
if (resolved.length === 0) {
73+
return;
74+
}
75+
76+
resolved.forEach((node) => {
77+
node.addEventListener("keydown", handleKeydown as EventListener, capture);
78+
});
79+
80+
return () => {
81+
resolved.forEach((node) => {
82+
node.removeEventListener("keydown", handleKeydown as EventListener, capture);
83+
});
84+
};
85+
}, [capture, enabled, handleKeydown, stableTargets]);
86+
}

0 commit comments

Comments
 (0)