Skip to content

Commit 203b8dc

Browse files
committed
feat(editor): give the PDF block width presets and an editable label
The PDF block took up too much vertical space and offered no way to name or size it without dragging. - halve the vertical padding: block margin, header, and page bar - add 50/75/100% width presets in the header (editable mode only). They write the existing `width` attribute, so drag-resizing still works and the two stay in sync - turn the file name into an editable field backed by a new `label` attribute (`data-pdf-label`). It falls back to the file name, so old documents are unchanged and re-saving them adds no attribute - keep downloads on the real file name — renaming the label must not strip the extension off the downloaded file - block mousedown on the preset buttons: it started a text selection that the following re-render spread across the whole header
1 parent d1bc062 commit 203b8dc

13 files changed

Lines changed: 468 additions & 44 deletions

File tree

dist/components/PdfViewer.svelte

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@
33
import { ChevronLeft, ChevronRight } from "lucide-svelte";
44
import { getPdfJs } from "../utils/pdf";
55
6-
let { src, fileName }: { src: string; fileName?: string } = $props();
6+
let {
7+
src,
8+
fileName,
9+
label
10+
}: { src: string; fileName?: string; label?: string } = $props();
711
812
const cleanName = (raw?: string) =>
913
(raw || "").replace(/[?#].*$/, "").trim() || "PDF 문서";
1014
15+
/** `label` 이 있으면 그것, 없으면 파일명. */
16+
const displayName = $derived(label?.trim() || cleanName(fileName));
17+
1118
let canvasEl: HTMLCanvasElement | undefined = $state();
1219
let containerEl: HTMLDivElement | undefined = $state();
1320
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -105,14 +112,12 @@
105112
<p class="text-sm text-muted-foreground animate-pulse">PDF 로딩 중...</p>
106113
</div>
107114
{:else}
108-
<div class="border border-border rounded-lg overflow-hidden my-4">
109-
<div
110-
class="flex items-center justify-between px-4 py-2 border-b border-border bg-muted"
111-
>
112-
<span class="text-xs text-muted-foreground truncate">
113-
{cleanName(fileName)}
115+
<div class="border border-border rounded-lg overflow-hidden my-2">
116+
<div class="flex items-center gap-2 px-3 py-1 border-b border-border bg-muted">
117+
<span class="text-xs text-muted-foreground truncate min-w-0">
118+
{displayName}
114119
</span>
115-
<span class="text-xs text-muted-foreground tabular-nums">
120+
<span class="text-xs text-muted-foreground tabular-nums ml-auto shrink-0">
116121
{page} / {total}
117122
</span>
118123
</div>
@@ -126,7 +131,7 @@
126131

127132
{#if total > 1}
128133
<div
129-
class="flex items-center justify-center gap-4 px-4 py-2 border-t border-border bg-muted"
134+
class="flex items-center justify-center gap-4 px-3 py-1 border-t border-border bg-muted"
130135
>
131136
<button
132137
type="button"

dist/components/PdfViewer.svelte.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
type $$ComponentProps = {
22
src: string;
33
fileName?: string;
4+
label?: string;
45
};
56
declare const PdfViewer: import("svelte").Component<$$ComponentProps, {}, "">;
67
type PdfViewer = ReturnType<typeof PdfViewer>;

dist/components/PdfViewer.svelte.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/extensions/PdfBlock.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/extensions/PdfBlock.js

Lines changed: 176 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export const PdfBlock = TiptapNode.create({
1111
fileId: { default: null },
1212
name: { default: "PDF" },
1313
width: { default: null },
14+
// 헤더에 보여줄 이름. `null` 이면 `name`(파일명)을 쓴다 — 옛 문서가 그대로 동작한다.
15+
label: { default: null },
1416
};
1517
},
1618
parseHTML() {
@@ -23,6 +25,7 @@ export const PdfBlock = TiptapNode.create({
2325
src: dom.getAttribute("data-pdf-src") || null,
2426
name: dom.getAttribute("data-pdf-name") || "PDF",
2527
width: dom.getAttribute("data-pdf-width") || dom.style?.width || null,
28+
label: dom.getAttribute("data-pdf-label") || null,
2629
}),
2730
},
2831
// URL 직접 방식
@@ -33,6 +36,7 @@ export const PdfBlock = TiptapNode.create({
3336
fileId: null,
3437
name: dom.getAttribute("data-pdf-name") || "PDF",
3538
width: dom.getAttribute("data-pdf-width") || dom.style?.width || null,
39+
label: dom.getAttribute("data-pdf-label") || null,
3640
}),
3741
},
3842
// 레거시: <embed type="application/pdf">
@@ -41,7 +45,7 @@ export const PdfBlock = TiptapNode.create({
4145
getAttrs: (dom) => {
4246
const src = dom.getAttribute("src") || "";
4347
const name = src.split("/").pop()?.replace(/[?#].*$/, "") || "PDF";
44-
return { src, fileId: null, name, width: null };
48+
return { src, fileId: null, name, width: null, label: null };
4549
},
4650
},
4751
];
@@ -53,6 +57,9 @@ export const PdfBlock = TiptapNode.create({
5357
if (HTMLAttributes.src)
5458
attrs["data-pdf-src"] = HTMLAttributes.src;
5559
attrs["data-pdf-name"] = HTMLAttributes.name || "PDF";
60+
// ⚠️ 값이 없으면 속성을 아예 뺀다 — 옛 문서와 출력이 구분되지 않게.
61+
if (HTMLAttributes.label)
62+
attrs["data-pdf-label"] = HTMLAttributes.label;
5663
if (HTMLAttributes.width) {
5764
attrs["data-pdf-width"] = HTMLAttributes.width;
5865
attrs["style"] = `width: ${HTMLAttributes.width}`;
@@ -85,7 +92,7 @@ export const PdfBlock = TiptapNode.create({
8592
let currentNode = node;
8693
let detachResize = null;
8794
const dom = document.createElement("div");
88-
dom.classList.add("my-4");
95+
dom.classList.add("my-2");
8996
dom.contentEditable = "false";
9097
dom.setAttribute("data-type", "pdfBlock");
9198
dom.setAttribute("data-drag-handle", "");
@@ -113,17 +120,166 @@ export const PdfBlock = TiptapNode.create({
113120
}
114121
// Header
115122
const header = document.createElement("div");
123+
// ⚠️ `justify-between` 이 아니라 spacer 로 민다. 이름칸이 넓어져도 오른쪽 버튼이
124+
// 밀려나지 않는다.
116125
header.className =
117-
"flex items-center justify-between px-4 py-2 border-b border-border";
126+
"flex items-center gap-2 px-3 py-1 border-b border-border select-none";
118127
header.style.background = "var(--muted)";
119128
wrapper.appendChild(header);
120129
const cleanName = (raw) => (raw || "").replace(/[?#].*$/, "").trim() || "PDF";
121-
const nameSpan = document.createElement("span");
122-
nameSpan.className = "text-xs text-muted-foreground truncate select-none";
123-
nameSpan.style.maxWidth = "200px";
124-
nameSpan.style.userSelect = "none";
125-
nameSpan.textContent = cleanName(node.attrs.name);
126-
header.appendChild(nameSpan);
130+
/**
131+
* 헤더 표시 이름.
132+
*
133+
* ⚠️ **표시 전용이다.** 다운로드 파일명은 계속 `name` 을 쓴다 — 표시 이름을
134+
* `1강 자료` 로 바꿨다고 확장자 없는 파일이 내려가면 안 된다.
135+
*/
136+
let resolvedName = null;
137+
const fallbackName = () => cleanName(resolvedName ?? currentNode.attrs.name);
138+
const displayName = () => {
139+
const label = currentNode.attrs.label;
140+
const trimmed = typeof label === "string" ? label.trim() : "";
141+
return trimmed || fallbackName();
142+
};
143+
let nameSpan = null;
144+
let nameInput = null;
145+
/** 노드 속성을 되쓴다. 리사이즈(`attachResize`)와 **같은 방식**. */
146+
const setAttrs = (patch) => {
147+
const pos = getPos();
148+
if (pos == null)
149+
return;
150+
editor.view.dispatch(editor.view.state.tr.setNodeMarkup(pos, undefined, {
151+
...currentNode.attrs,
152+
...patch,
153+
}));
154+
};
155+
/** 입력칸이 글자 길이만큼만 차지하게 한다(빈 상자가 넓게 보이지 않도록). */
156+
const fitInput = () => {
157+
if (!nameInput)
158+
return;
159+
nameInput.size = Math.min(40, Math.max(6, nameInput.value.length + 1));
160+
};
161+
/** 표시 이름을 화면에 반영한다. 입력 중일 때는 건드리지 않는다. */
162+
const syncName = () => {
163+
const next = displayName();
164+
if (nameSpan)
165+
nameSpan.textContent = next;
166+
if (nameInput &&
167+
document.activeElement !== nameInput &&
168+
nameInput.value !== next) {
169+
nameInput.value = next;
170+
}
171+
fitInput();
172+
};
173+
if (editor.isEditable) {
174+
// 그 자리에서 이름을 고쳐 쓴다. 확정은 blur / Enter, 되돌리기는 Escape.
175+
nameInput = document.createElement("input");
176+
nameInput.type = "text";
177+
nameInput.className =
178+
// ⚠️ `select-text` — 헤더가 `select-none` 이라 이 칸까지 선택이 막힌다. 이름을
179+
// 드래그해 고쳐 쓰려면 여기만 되돌려야 한다.
180+
/*
181+
* ⚠️ 평소에도 **입력칸으로 보여야 한다**(사용자 지적). 예전엔 테두리·면이 투명이라
182+
* hover 하기 전에는 그냥 글자였고, 고칠 수 있다는 걸 알 방법이 없었다.
183+
* 헤더 면(`--muted`) 위에 `--background` 면 + 1px 테두리를 두면 한눈에 칸으로 읽힌다.
184+
*/
185+
"pdf-name-input select-text text-xs text-muted-foreground min-w-0 bg-background rounded px-1.5 py-0.5 border border-border hover:border-ring focus:border-ring focus:text-foreground outline-none transition-colors";
186+
nameInput.style.maxWidth = "220px";
187+
nameInput.contentEditable = "false";
188+
nameInput.draggable = false;
189+
// ProseMirror 가 이 칸의 키/포인터를 가져가지 않도록 하는 표식(`stopEvent`).
190+
nameInput.setAttribute("data-pdf-control", "");
191+
nameInput.title = "표시 이름 (비우면 파일명)";
192+
nameInput.setAttribute("aria-label", "PDF 표시 이름");
193+
nameInput.value = displayName();
194+
fitInput();
195+
/** 빈칸이거나 파일명과 같으면 `label: null` — 다시 파일명을 따라간다. */
196+
const commitLabel = () => {
197+
if (!nameInput)
198+
return;
199+
const typed = nameInput.value.trim();
200+
const next = typed && typed !== fallbackName() ? typed : null;
201+
const current = currentNode.attrs.label ?? null;
202+
if (next !== current)
203+
setAttrs({ label: next });
204+
nameInput.value = next || fallbackName();
205+
fitInput();
206+
};
207+
nameInput.addEventListener("input", fitInput);
208+
nameInput.addEventListener("keydown", (e) => {
209+
// tiptap 이 단축키·타이핑을 가로채면 이 칸에 글자가 안 들어간다.
210+
e.stopPropagation();
211+
if (e.key === "Enter") {
212+
e.preventDefault();
213+
commitLabel();
214+
nameInput?.blur();
215+
}
216+
else if (e.key === "Escape") {
217+
e.preventDefault();
218+
// 되돌린 뒤 blur — 값이 현재와 같아져 blur 의 커밋은 무시된다.
219+
if (nameInput)
220+
nameInput.value = displayName();
221+
nameInput?.blur();
222+
}
223+
});
224+
nameInput.addEventListener("mousedown", (e) => e.stopPropagation());
225+
nameInput.addEventListener("blur", commitLabel);
226+
header.appendChild(nameInput);
227+
}
228+
else {
229+
nameSpan = document.createElement("span");
230+
nameSpan.className =
231+
"text-xs text-muted-foreground truncate select-none min-w-0";
232+
nameSpan.style.maxWidth = "220px";
233+
nameSpan.style.userSelect = "none";
234+
nameSpan.textContent = displayName();
235+
header.appendChild(nameSpan);
236+
}
237+
const spacer = document.createElement("div");
238+
spacer.className = "flex-1";
239+
header.appendChild(spacer);
240+
// 너비 프리셋 (편집 가능 모드에서만)
241+
const PRESET_BASE = "px-1.5 py-0.5 rounded text-xs leading-none tabular-nums transition-colors select-none";
242+
const PRESET_IDLE = `${PRESET_BASE} text-muted-foreground hover:bg-background pdf-width-preset`;
243+
const PRESET_ACTIVE = `${PRESET_BASE} bg-primary text-primary-foreground pdf-width-preset is-active`;
244+
const presetButtons = [];
245+
if (editor.isEditable) {
246+
const presetGroup = document.createElement("div");
247+
presetGroup.className = "flex items-center gap-0.5";
248+
// ⚠️ 25% 는 뺐다 — 그 폭이면 PDF 글자를 읽을 수 없어 고를 이유가 없다(사용자 결정).
249+
for (const value of ["50%", "75%", "100%"]) {
250+
const btn = document.createElement("button");
251+
btn.type = "button";
252+
btn.className = PRESET_IDLE;
253+
btn.title = `너비 ${value}`;
254+
btn.textContent = value;
255+
/*
256+
* ⚠️ `mousedown` 에서 기본 동작을 막는다. 안 막으면 브라우저가 여기서 텍스트 선택을
257+
* 시작하고, 곧바로 `setAttrs` 가 NodeView 를 다시 그리면서 그 선택이 **헤더 전체**로
258+
* 번져 파일명·버튼이 통째로 파랗게 칠해졌다(사용자 지적).
259+
* 클릭 자체는 `click` 에서 처리하므로 동작에는 영향이 없다.
260+
*/
261+
btn.addEventListener("mousedown", (e) => {
262+
e.preventDefault();
263+
e.stopPropagation();
264+
});
265+
btn.addEventListener("click", (e) => {
266+
e.preventDefault();
267+
e.stopPropagation();
268+
setAttrs({ width: value });
269+
});
270+
presetGroup.appendChild(btn);
271+
presetButtons.push({ value, el: btn });
272+
}
273+
header.appendChild(presetGroup);
274+
}
275+
/** 현재 `width` 와 같은 프리셋을 눌린 상태로 만든다. */
276+
const syncPresets = (width) => {
277+
for (const { value, el } of presetButtons) {
278+
el.className = width === value ? PRESET_ACTIVE : PRESET_IDLE;
279+
el.setAttribute("aria-pressed", width === value ? "true" : "false");
280+
}
281+
};
282+
syncPresets(node.attrs.width ?? null);
127283
const btnGroup = document.createElement("div");
128284
btnGroup.className = "flex items-center gap-1";
129285
header.appendChild(btnGroup);
@@ -261,7 +417,7 @@ export const PdfBlock = TiptapNode.create({
261417
if (totalPages > 1) {
262418
navDiv = document.createElement("div");
263419
navDiv.className =
264-
"flex items-center justify-center gap-4 px-4 py-2 border-t border-border";
420+
"flex items-center justify-center gap-4 px-3 py-1 border-t border-border";
265421
navDiv.style.background = "var(--muted)";
266422
const prevBtn = document.createElement("button");
267423
prevBtn.type = "button";
@@ -341,8 +497,10 @@ export const PdfBlock = TiptapNode.create({
341497
resolver(node.attrs.fileId)
342498
.then((result) => {
343499
if (result.name) {
344-
nameSpan.textContent = result.name;
500+
// 다운로드 파일명은 항상 실제 파일명. 표시는 `label` 이 있으면 그쪽이 이긴다.
501+
resolvedName = result.name;
345502
downloadLink.setAttribute("download", result.name);
503+
syncName();
346504
}
347505
})
348506
.catch(() => { });
@@ -359,8 +517,15 @@ export const PdfBlock = TiptapNode.create({
359517
dom.style.width = newWidth || "";
360518
}
361519
currentNode = updatedNode;
520+
syncPresets(newWidth ?? null);
521+
syncName();
362522
return true;
363523
},
524+
// 이름 입력칸 위의 이벤트는 ProseMirror 가 가로채면 안 된다(CardBlock 과 같은 방식).
525+
stopEvent: (event) => {
526+
const target = event.target;
527+
return target instanceof Element && !!target.closest("[data-pdf-control]");
528+
},
364529
selectNode: () => { },
365530
deselectNode: () => { },
366531
destroy: () => {

dist/extensions/PdfBlock.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,36 @@ describe('PdfBlock extension', () => {
4242
expect(html).toContain('data-pdf-src="https://example.com/doc.pdf"');
4343
expect(html).toContain('data-pdf-name="doc.pdf"');
4444
});
45+
it('leaves label null and omits the attribute for legacy HTML', () => {
46+
createEditor('<div data-pdf-src="https://example.com/doc.pdf" data-pdf-name="doc.pdf"></div>');
47+
const pdfNode = editor
48+
.getJSON()
49+
.content?.find((n) => n.type === 'pdfBlock');
50+
expect(pdfNode?.attrs?.label).toBeNull();
51+
// 값이 없으면 속성을 아예 뺀다 — 옛 문서와 출력이 구분되지 않아야 한다.
52+
expect(editor.getHTML()).not.toContain('data-pdf-label');
53+
});
54+
it('round-trips label through parse → render → parse', () => {
55+
createEditor('<div data-pdf-src="https://example.com/doc.pdf" data-pdf-name="doc.pdf" data-pdf-label="1강 자료"></div>');
56+
const html = editor.getHTML();
57+
expect(html).toContain('data-pdf-label="1강 자료"');
58+
// 다운로드에 쓰이는 실제 파일명은 그대로 살아 있어야 한다.
59+
expect(html).toContain('data-pdf-name="doc.pdf"');
60+
createEditor(html);
61+
const pdfNode = editor
62+
.getJSON()
63+
.content?.find((n) => n.type === 'pdfBlock');
64+
expect(pdfNode?.attrs?.label).toBe('1강 자료');
65+
expect(pdfNode?.attrs?.name).toBe('doc.pdf');
66+
});
67+
it('keeps the width attribute a preset button would set', () => {
68+
createEditor('<div data-pdf-src="https://example.com/doc.pdf" data-pdf-width="50%"></div>');
69+
const pdfNode = editor
70+
.getJSON()
71+
.content?.find((n) => n.type === 'pdfBlock');
72+
expect(pdfNode?.attrs?.width).toBe('50%');
73+
expect(editor.getHTML()).toContain('data-pdf-width="50%"');
74+
});
4575
it('uses default name "PDF" when name is missing', () => {
4676
createEditor('<div data-pdf-src="https://example.com/x.pdf"></div>');
4777
const doc = editor.getJSON();

dist/styles/editor.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)