feat: Ctrl+S 로 저장한다
브라우저의 Ctrl+S 는 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장 대화상자가 뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다. 문서·프로젝트·릴리즈 세 편집기에서 기본 동작을 막고 그 화면의 저장으로 돌린다. 저장할 것이 없을 때도 기본 동작은 막는다. 막지 않으면 "저장할 것이 없다"는 화면 상태와 브라우저의 저장 대화상자가 동시에 나와, 무엇이 일어났는지 알 수 없다. Ctrl+Shift+S 와 Alt 조합은 가로채지 않는다 — 다른 뜻으로 쓰는 곳이 있다. 버튼에 단축키를 적어 둔다. 단축키는 알려 주지 않으면 없는 것과 같다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
co-authored by
Claude Opus 5
parent
fd73bc88a1
commit
3036b8d788
@@ -15,6 +15,7 @@ import { DocumentEditor } from "./document-editor.tsx";
|
|||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
|
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
|
||||||
import { slugFromName } from "./slug-from-name.ts";
|
import { slugFromName } from "./slug-from-name.ts";
|
||||||
|
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||||
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
@@ -176,6 +177,12 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
|
|||||||
}
|
}
|
||||||
}, [begin, editor, setStatus, studio]);
|
}, [begin, editor, setStatus, studio]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
`save` 가 스스로 CLEAN·SAVING·CONFLICT 를 걸러 내므로 단축키는 늘 열어 둔다 — 저장할 것이
|
||||||
|
없을 때 눌러도 아무 일도 일어나지 않는다.
|
||||||
|
*/
|
||||||
|
useSaveShortcut(save);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다.
|
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function DocumentStatusRail({
|
|||||||
<h2 id="studio-document-status-title">작업 상태</h2>
|
<h2 id="studio-document-status-title">작업 상태</h2>
|
||||||
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
|
||||||
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
<dl><div><dt>저장 버전</dt><dd>{controller.saved.version}</dd></div><div><dt>종류</dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
|
||||||
<button type="button" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
<button type="button" title="Ctrl+S" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
|
||||||
{/*
|
{/*
|
||||||
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
|
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
|
||||||
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
|
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
|
||||||
@@ -63,7 +63,7 @@ export function DocumentStatusRail({
|
|||||||
) : controller.publishError ? (
|
) : controller.publishError ? (
|
||||||
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
|
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
|
||||||
) : (
|
) : (
|
||||||
<p>불완전한 초안도 저장할 수 있습니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.</p>
|
<p>불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.</p>
|
||||||
)}
|
)}
|
||||||
{unplacedIssues.length ? (
|
{unplacedIssues.length ? (
|
||||||
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
|
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { managementFailureMessage } from "../../../application/ports/management-
|
|||||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
import { slugFromName } from "./slug-from-name.ts";
|
import { slugFromName } from "./slug-from-name.ts";
|
||||||
|
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||||
@@ -211,6 +212,12 @@ export function ProjectEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 폼 제출과 같은 일을 하므로 submit 이벤트를 만들어 보낸다 — 검증·기본값 처리가 한 곳에 남는다.
|
||||||
|
useSaveShortcut(
|
||||||
|
() => void save({ preventDefault: () => {} } as FormEvent),
|
||||||
|
Boolean(project && draft) && !pending,
|
||||||
|
);
|
||||||
|
|
||||||
const published = project ? project.targetVisibility !== "PRIVATE" : false;
|
const published = project ? project.targetVisibility !== "PRIVATE" : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -383,7 +390,7 @@ export function ProjectEditor() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="studio-editor-footer">
|
<div className="studio-editor-footer">
|
||||||
<button className="studio-primary-button" type="submit" disabled={pending}>
|
<button className="studio-primary-button" type="submit" title="Ctrl+S" disabled={pending}>
|
||||||
{pending ? "저장하는 중" : "저장"}
|
{pending ? "저장하는 중" : "저장"}
|
||||||
</button>
|
</button>
|
||||||
<p className="studio-field-note">
|
<p className="studio-field-note">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||||
|
import { useSaveShortcut } from "./use-save-shortcut.ts";
|
||||||
import { useStudio } from "../use-studio.ts";
|
import { useStudio } from "../use-studio.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,6 +193,8 @@ export function ReleaseEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useSaveShortcut(save, draft !== null && !pending);
|
||||||
|
|
||||||
const toggleChangeType = (value: string) => {
|
const toggleChangeType = (value: string) => {
|
||||||
if (draft === null) return;
|
if (draft === null) return;
|
||||||
update({
|
update({
|
||||||
@@ -309,6 +312,7 @@ export function ReleaseEditor() {
|
|||||||
<button
|
<button
|
||||||
className="studio-primary-button"
|
className="studio-primary-button"
|
||||||
type="button"
|
type="button"
|
||||||
|
title="Ctrl+S"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ctrl+S(맥에서는 Cmd+S)로 저장한다.
|
||||||
|
*
|
||||||
|
* <p>브라우저의 기본 동작은 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장 대화상자가
|
||||||
|
* 뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다. 그래서 기본 동작을 막고 이
|
||||||
|
* 화면의 저장으로 돌린다.
|
||||||
|
*
|
||||||
|
* <p>`enabled` 가 거짓이면 아무것도 하지 않는다. 저장할 것이 없거나 이미 저장 중일 때 단축키가 요청을
|
||||||
|
* 겹쳐 보내지 않게 하는 것은 호출자의 몫이다 — 화면마다 "지금 저장할 수 있는가"의 뜻이 다르다.
|
||||||
|
*/
|
||||||
|
export function useSaveShortcut(
|
||||||
|
save: () => void | Promise<void>,
|
||||||
|
enabled = true,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "s" && event.key !== "S") return;
|
||||||
|
if (!(event.ctrlKey || event.metaKey)) return;
|
||||||
|
// Ctrl+Shift+S 는 다른 뜻으로 쓰는 곳이 있어 가로채지 않는다.
|
||||||
|
if (event.shiftKey || event.altKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (enabled) void save();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [enabled, save]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { afterEach, test } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useSaveShortcut } from "../../../src/features/tech-log/presentation/studio/components/use-save-shortcut.ts";
|
||||||
|
|
||||||
|
function pressSave(overrides: Partial<KeyboardEventInit> = {}) {
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: "s",
|
||||||
|
ctrlKey: true,
|
||||||
|
cancelable: true,
|
||||||
|
bubbles: true,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
브라우저의 Ctrl+S 는 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장 대화상자가
|
||||||
|
뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다. 그래서 기본 동작을 막는
|
||||||
|
것까지가 이 훅의 일이다.
|
||||||
|
*/
|
||||||
|
test("Ctrl+S saves and keeps the browser from offering the page as a file", () => {
|
||||||
|
let saved = 0;
|
||||||
|
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||||
|
|
||||||
|
const event = pressSave();
|
||||||
|
|
||||||
|
assert.equal(saved, 1);
|
||||||
|
assert.equal(event.defaultPrevented, true);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Cmd+S does the same, because the habit is the same on a Mac", () => {
|
||||||
|
let saved = 0;
|
||||||
|
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||||
|
|
||||||
|
pressSave({ ctrlKey: false, metaKey: true });
|
||||||
|
|
||||||
|
assert.equal(saved, 1);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
저장할 것이 없을 때도 기본 동작은 막는다. 막지 않으면 "저장할 것이 없다"는 화면 상태와
|
||||||
|
브라우저의 저장 대화상자가 동시에 나와, 무엇이 일어났는지 알 수 없다.
|
||||||
|
*/
|
||||||
|
test("a disabled shortcut still swallows the browser default", () => {
|
||||||
|
let saved = 0;
|
||||||
|
const { unmount } = renderHook(() =>
|
||||||
|
useSaveShortcut(() => { saved += 1; }, false),
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = pressSave();
|
||||||
|
|
||||||
|
assert.equal(saved, 0);
|
||||||
|
assert.equal(event.defaultPrevented, true);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modified chords are left to whoever else wants them", () => {
|
||||||
|
let saved = 0;
|
||||||
|
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||||
|
|
||||||
|
const shift = pressSave({ shiftKey: true });
|
||||||
|
const alt = pressSave({ altKey: true });
|
||||||
|
const plain = pressSave({ ctrlKey: false });
|
||||||
|
|
||||||
|
assert.equal(saved, 0);
|
||||||
|
assert.equal(shift.defaultPrevented, false);
|
||||||
|
assert.equal(alt.defaultPrevented, false);
|
||||||
|
assert.equal(plain.defaultPrevented, false);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the listener goes away with the screen", () => {
|
||||||
|
let saved = 0;
|
||||||
|
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
pressSave();
|
||||||
|
|
||||||
|
assert.equal(saved, 0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user