@teriusu/rich-editor 0.9.8 → 0.9.10

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.
@@ -91,6 +91,7 @@ export const PdfBlock = TiptapNode.create({
91
91
  // 클로저의 `node` 를 쓰면 그 사이의 다른 속성 변경을 리사이즈가 되돌린다.
92
92
  let currentNode = node;
93
93
  let detachResize = null;
94
+ const icon = (paths) => `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
94
95
  const dom = document.createElement("div");
95
96
  dom.classList.add("my-2");
96
97
  dom.contentEditable = "false";
@@ -102,11 +103,9 @@ export const PdfBlock = TiptapNode.create({
102
103
  dom.style.maxWidth = "100%";
103
104
  if (node.attrs.width)
104
105
  dom.style.width = node.attrs.width;
105
- const wrapper = document.createElement("div");
106
- wrapper.className =
107
- "border rounded-lg overflow-hidden bg-muted/30 transition-shadow";
108
- wrapper.style.borderColor = "var(--border)";
109
- dom.appendChild(wrapper);
106
+ const frame = document.createElement("div");
107
+ frame.className = "pdf-frame";
108
+ dom.appendChild(frame);
110
109
  // 리사이즈 핸들 (편집 가능 모드에서만)
111
110
  if (editor.isEditable) {
112
111
  detachResize = attachResize({
@@ -118,30 +117,30 @@ export const PdfBlock = TiptapNode.create({
118
117
  label: "PDF 너비 조절",
119
118
  });
120
119
  }
121
- // Header
122
- const header = document.createElement("div");
123
- // ⚠️ `justify-between` 이 아니라 spacer 로 민다. 이름칸이 넓어져도 오른쪽 버튼이
124
- // 밀려나지 않는다.
125
- header.className =
126
- "flex items-center gap-2 px-3 py-1 border-b border-border select-none";
127
- header.style.background = "var(--muted)";
128
- wrapper.appendChild(header);
120
+ /*
121
+ * 조작부는 판 **안에** 떠 있다. 통째로 `data-pdf-control` 이라 `stopEvent` 가
122
+ * ProseMirror 를 막아 준다 — 버튼·입력칸이 선택/타이핑에 가로채이지 않는다.
123
+ * 빈 영역은 `pointer-events: none` 이라 그 위로 드래그·클릭이 그대로 통과한다.
124
+ */
125
+ const overlay = document.createElement("div");
126
+ overlay.className = "pdf-overlay";
127
+ overlay.setAttribute("data-pdf-control", "");
128
+ // 판에는 캔버스·상태문구 **뒤에** 붙인다(아래) — 여기서는 만들어 채우기만 한다.
129
+ const endCluster = document.createElement("div");
130
+ endCluster.className = "pdf-cluster pdf-cluster-end";
131
+ overlay.appendChild(endCluster);
129
132
  const cleanName = (raw) => (raw || "").replace(/[?#].*$/, "").trim() || "PDF";
130
- /**
131
- * 헤더 표시 이름.
133
+ /*
134
+ * ⚠️ **표시 이름은 화면에서 뺐다**(사용자 결정) — 편집용 입력칸도, 읽기용 글자도
135
+ * 없다. 판 위에 이름표가 늘 붙어 있는 게 소음이었다.
132
136
  *
133
- * ⚠️ **표시 전용이다.** 다운로드 파일명은 계속 `name` 을 쓴다 — 표시 이름을
134
- * `1강 자료` 로 바꿨다고 확장자 없는 파일이 내려가면 안 된다.
137
+ * 그래도 `label` 속성 자체는 **스키마에 남긴다.** 이미 이름을 지정해 둔 문서가
138
+ * 있고, 속성을 지우면 그 문서를 한 번 열었다 저장하는 것만으로 값이 조용히
139
+ * 사라진다. 지금은 아무도 읽지 않지만 왕복은 그대로 된다.
140
+ *
141
+ * 다운로드 파일명은 원래부터 `label` 이 아니라 `name` 이다 — 표시 이름을
142
+ * `1강 자료` 로 바꿨다고 확장자 없는 파일이 내려가면 안 되기 때문.
135
143
  */
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
144
  /** 노드 속성을 되쓴다. 리사이즈(`attachResize`)와 **같은 방식**. */
146
145
  const setAttrs = (patch) => {
147
146
  const pos = getPos();
@@ -152,110 +151,58 @@ export const PdfBlock = TiptapNode.create({
152
151
  ...patch,
153
152
  }));
154
153
  };
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`;
154
+ /*
155
+ * ── 배율 프리셋 ────────────────────────────────────────────────────
156
+ *
157
+ * ⚠️ 이 칩들은 한때 **칼럼 대비 비율**(`width: 50%`)을 썼다. 그게 틀린 축이었다.
158
+ * 저자가 고르는 건 "칼럼의 절반"이 아니라 "이 정도 크기로 보였으면"인데, 칼럼 폭은
159
+ * 화면마다 다르다 — 문제 편집 화면은 1378px 이지만 코딩 패널 안에서는 훨씬 좁고,
160
+ * 거기서 같은 50% 는 절반 이하로 줄어든다. 저장된 값이 **보는 곳마다 다른 크기**가
161
+ * 된다는 뜻이다. (손잡이 드래그는 원래부터 px 로 저장하고 있었으니 한 문서 안에
162
+ * 두 체계가 섞여 있기도 했다.)
163
+ *
164
+ * 기준은 **PDF 본문 글자가 화면에서 몇 px 로 그려지느냐**다 — 그게 곧 읽히느냐다.
165
+ * pdf.js 는 배율 1 에서 1 PDF 단위 = 1 CSS px 이라 그대로 환산된다:
166
+ *
167
+ * 배율 A4 세로 폭 10pt 본문 쓸모
168
+ * 50% 298px 5px 썸네일(읽기 불가)
169
+ * 75% 446px 7.5px 도표 파악 · 가로형 슬라이드는 읽힘
170
+ * 100% 595px 10px 읽을 수 있음(작음) — 기준점
171
+ * 150% 893px 15px 편하게 읽음
172
+ *
173
+ * 배율은 **페이지 원본 크기 기준**이라 방향·용지 크기를 알아서 반영한다. 같은 칩이
174
+ * 세로형에는 595px, 가로형에는 842px 을 준다 — 글자 크기가 같으니 맞는 결과다.
175
+ * (폭 기준이던 시절 세로형이 부당하게 커 보이던 것이 이래서였다.)
176
+ *
177
+ * ⚠️ `폭맞춤`(칼럼 채우기)은 **일부러 뺐다.** 1378px 칼럼에서 세로형에 걸면 배율
178
+ * 231%, 높이 1950px 로 화면 밖으로 나간다 — 저자 의도가 아니라 우연히 나오는 값이다.
179
+ */
180
+ const PRESET_IDLE = "pdf-preset";
181
+ const PRESET_ACTIVE = "pdf-preset is-active";
182
+ const ZOOMS = [0.5, 0.75, 1, 1.5];
244
183
  const presetButtons = [];
184
+ /** 페이지 원본 폭(CSS px). PDF 를 읽기 전에는 모른다. */
185
+ let naturalWidth = 0;
186
+ /** 배율 → 저장할 px. 원본 폭을 아직 모르면 `null`. */
187
+ const zoomToWidth = (zoom) => naturalWidth > 0 ? `${Math.round(naturalWidth * zoom)}px` : null;
245
188
  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%"]) {
189
+ /*
190
+ * ⚠️ 프리셋은 **문서에 저장되는 크기**이지 보기 상태가 아니다. 옆의 다운로드·새 탭과
191
+ * 성격이 달라(저장됨 vs 그때뿐) 구분선으로 갈라 둔다.
192
+ */
193
+ for (const zoom of ZOOMS) {
194
+ const label = `${zoom * 100}%`;
250
195
  const btn = document.createElement("button");
251
196
  btn.type = "button";
252
197
  btn.className = PRESET_IDLE;
253
- btn.title = `너비 ${value}`;
254
- btn.textContent = value;
198
+ btn.title = `페이지 원본 크기의 ${label}`;
199
+ btn.textContent = label;
200
+ // 원본 폭을 알기 전(로딩 중)에는 누를 수 없다 — 계산할 근거가 없다.
201
+ btn.disabled = true;
255
202
  /*
256
203
  * ⚠️ `mousedown` 에서 기본 동작을 막는다. 안 막으면 브라우저가 여기서 텍스트 선택을
257
- * 시작하고, 곧바로 `setAttrs` 가 NodeView 를 다시 그리면서 그 선택이 **헤더 전체**로
258
- * 번져 파일명·버튼이 통째로 파랗게 칠해졌다(사용자 지적).
204
+ * 시작하고, 곧바로 `setAttrs` 가 NodeView 를 다시 그리면서 그 선택이 **조작부 전체**로
205
+ * 번져 칩·버튼이 통째로 파랗게 칠해졌다(사용자 지적).
259
206
  * 클릭 자체는 `click` 에서 처리하므로 동작에는 영향이 없다.
260
207
  */
261
208
  btn.addEventListener("mousedown", (e) => {
@@ -265,32 +212,41 @@ export const PdfBlock = TiptapNode.create({
265
212
  btn.addEventListener("click", (e) => {
266
213
  e.preventDefault();
267
214
  e.stopPropagation();
268
- setAttrs({ width: value });
215
+ const next = zoomToWidth(zoom);
216
+ if (next)
217
+ setAttrs({ width: next });
269
218
  });
270
- presetGroup.appendChild(btn);
271
- presetButtons.push({ value, el: btn });
219
+ endCluster.appendChild(btn);
220
+ presetButtons.push({ zoom, el: btn });
272
221
  }
273
- header.appendChild(presetGroup);
222
+ const divider = document.createElement("div");
223
+ divider.className = "pdf-divider";
224
+ endCluster.appendChild(divider);
274
225
  }
275
- /** 현재 `width` 와 같은 프리셋을 눌린 상태로 만든다. */
226
+ /**
227
+ * 현재 `width` 와 같은 프리셋을 눌린 상태로 만든다.
228
+ *
229
+ * ⚠️ 문자열 비교로는 안 된다. 저장값이 `"893px"` 처럼 계산된 px 라 반올림 오차가
230
+ * 있고, 손잡이로 끌면 아무 px 이나 들어온다. 1px 여유로 견준다.
231
+ * 옛 문서의 `"50%"` 같은 값은 어느 칩과도 안 맞는다 — **그대로 두는 게 맞다.**
232
+ * 저자가 다시 고를 때까지 예전 크기로 렌더되고, 스키마는 건드리지 않는다.
233
+ */
276
234
  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");
235
+ const px = width?.endsWith("px") ? Number.parseFloat(width) : NaN;
236
+ for (const { zoom, el } of presetButtons) {
237
+ const target = naturalWidth > 0 ? naturalWidth * zoom : NaN;
238
+ const on = Number.isFinite(px) && Math.abs(px - target) <= 1;
239
+ el.className = on ? PRESET_ACTIVE : PRESET_IDLE;
240
+ el.setAttribute("aria-pressed", on ? "true" : "false");
280
241
  }
281
242
  };
282
243
  syncPresets(node.attrs.width ?? null);
283
- const btnGroup = document.createElement("div");
284
- btnGroup.className = "flex items-center gap-1";
285
- header.appendChild(btnGroup);
286
244
  const downloadLink = document.createElement("a");
287
245
  downloadLink.rel = "noopener noreferrer";
288
246
  downloadLink.setAttribute("download", cleanName(node.attrs.name));
289
- downloadLink.className =
290
- "p-1 rounded hover:bg-muted transition-colors text-muted-foreground cursor-pointer";
247
+ downloadLink.className = "pdf-ctl";
291
248
  downloadLink.title = "\uB2E4\uC6B4\uB85C\uB4DC";
292
- downloadLink.innerHTML =
293
- '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
249
+ downloadLink.innerHTML = icon('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>');
294
250
  downloadLink.addEventListener("click", async (e) => {
295
251
  if (!downloadLink.href)
296
252
  return;
@@ -314,68 +270,178 @@ export const PdfBlock = TiptapNode.create({
314
270
  window.open(downloadLink.href, "_blank", "noopener,noreferrer");
315
271
  }
316
272
  });
317
- btnGroup.appendChild(downloadLink);
273
+ endCluster.appendChild(downloadLink);
274
+ /*
275
+ * \uC804\uCCB4\uD654\uBA74 \uB300\uC2E0 **\uC0C8 \uD0ED**\uC774\uB2E4(\uC0AC\uC6A9\uC790 \uACB0\uC815). \uBE0C\uB77C\uC6B0\uC800 \uAE30\uBCF8 PDF \uBDF0\uC5B4\uAC00 \uCC3D \uC804\uCCB4\uB97C \uC4F0\uACE0
276
+ * \uAC80\uC0C9\u00B7\uC778\uC1C4\u00B7\uD655\uB300\uAE4C\uC9C0 \uC5B9\uD600 \uC624\uBBC0\uB85C, \uC6B0\uB9AC\uAC00 \uB9CC\uB4E0 \uC804\uCCB4\uD654\uBA74\uBCF4\uB2E4 \uC77D\uAE30\uC5D0 \uB0AB\uB2E4.
277
+ */
318
278
  const openLink = document.createElement("a");
319
279
  openLink.target = "_blank";
320
280
  openLink.rel = "noopener noreferrer";
321
- openLink.className =
322
- "p-1 rounded hover:bg-muted transition-colors text-muted-foreground";
281
+ openLink.className = "pdf-ctl";
323
282
  openLink.title = "\uC0C8 \uD0ED\uC5D0\uC11C \uC5F4\uAE30";
324
- openLink.innerHTML =
325
- '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>';
326
- btnGroup.appendChild(openLink);
283
+ openLink.innerHTML = icon('<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>');
284
+ endCluster.appendChild(openLink);
327
285
  if (editor.isEditable) {
328
286
  const deleteBtn = document.createElement("button");
329
287
  deleteBtn.type = "button";
330
- deleteBtn.className =
331
- "p-1 rounded hover:bg-destructive/10 transition-colors text-muted-foreground hover:text-destructive";
288
+ deleteBtn.className = "pdf-ctl pdf-ctl-danger";
332
289
  deleteBtn.title = "\uC0AD\uC81C";
333
- deleteBtn.innerHTML =
334
- '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>';
290
+ deleteBtn.innerHTML = icon('<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>');
335
291
  deleteBtn.addEventListener("click", () => {
336
292
  const pos = getPos();
337
293
  if (pos != null) {
338
294
  editor.commands.deleteRange({
339
295
  from: pos,
340
- to: pos + node.nodeSize,
296
+ to: pos + currentNode.nodeSize,
341
297
  });
342
298
  }
343
299
  });
344
- btnGroup.appendChild(deleteBtn);
300
+ endCluster.appendChild(deleteBtn);
345
301
  }
346
- // Content area
347
- const contentArea = document.createElement("div");
348
- contentArea.className = "flex items-center justify-center";
349
- wrapper.appendChild(contentArea);
350
302
  const canvas = document.createElement("canvas");
351
- canvas.className = "shadow-sm";
352
- contentArea.appendChild(canvas);
353
- const loadingDiv = document.createElement("div");
354
- loadingDiv.className = "flex items-center justify-center p-8";
355
- loadingDiv.innerHTML =
356
- '<p class="text-sm text-muted-foreground animate-pulse">PDF \uB85C\uB529 \uC911...</p>';
357
- contentArea.appendChild(loadingDiv);
303
+ frame.appendChild(canvas);
304
+ const statusDiv = document.createElement("div");
305
+ statusDiv.className = "pdf-status";
306
+ statusDiv.textContent = "PDF \uB85C\uB529 \uC911...";
307
+ frame.appendChild(statusDiv);
358
308
  canvas.style.display = "none";
359
- let navDiv = null;
309
+ // \uC870\uC791\uBD80\uB294 \uB9C8\uC9C0\uB9C9 \u2014 `position:absolute` \uB77C \uC21C\uC11C\uC640 \uBB34\uAD00\uD558\uAC8C \uC704\uC5D0 \uADF8\uB824\uC9C0\uC9C0\uB9CC,
310
+ // \uB0B4\uC6A9 \uB2E4\uC74C\uC5D0 \uC624\uB294 \uAC8C \uBCF4\uC870\uAE30\uC220\uC5D0\uAC8C\uB3C4 \uB9DE\uB294 \uC21C\uC11C\uB2E4.
311
+ frame.appendChild(overlay);
312
+ /* \u2500\u2500 \uD398\uC774\uC9C0 \uC774\uB3D9 \u2014 \uC88C\uC6B0 \uD654\uC0B4\uD45C + \uC544\uB798 \uAC00\uC6B4\uB370 \uCABD\uC218 \u2500\u2500 */
313
+ const prevBtn = document.createElement("button");
314
+ prevBtn.type = "button";
315
+ prevBtn.className = "pdf-ctl pdf-nav pdf-nav-prev pdf-cluster";
316
+ prevBtn.title = "\uC774\uC804 \uD398\uC774\uC9C0";
317
+ prevBtn.setAttribute("aria-label", "\uC774\uC804 \uD398\uC774\uC9C0");
318
+ prevBtn.innerHTML = icon('<path d="m15 18-6-6 6-6"/>');
319
+ const nextBtn = document.createElement("button");
320
+ nextBtn.type = "button";
321
+ nextBtn.className = "pdf-ctl pdf-nav pdf-nav-next pdf-cluster";
322
+ nextBtn.title = "\uB2E4\uC74C \uD398\uC774\uC9C0";
323
+ nextBtn.setAttribute("aria-label", "\uB2E4\uC74C \uD398\uC774\uC9C0");
324
+ nextBtn.innerHTML = icon('<path d="m9 18 6-6-6-6"/>');
325
+ /*
326
+ * \uD310\uC774 \uD654\uBA74\uBCF4\uB2E4 \uAE38 \uB54C \uC77D\uAE30 \uC870\uC791\uBD80\uAC00 \uB530\uB77C\uC624\uAC8C \uD558\uB294 \uB808\uC77C. `position: sticky` \uB77C
327
+ * \uD310\uC744 \uC9C0\uB098\uB294 \uB3D9\uC548 \uD56D\uC0C1 \uBCF4\uC774\uB294 \uC601\uC5ED\uC5D0 \uBA38\uBB34\uB978\uB2E4. \uD310\uC774 \uD654\uBA74\uBCF4\uB2E4 \uC9E7\uC73C\uBA74
328
+ * `max-height: 100%` \uAC00 \uB808\uC77C\uC744 \uD310 \uD06C\uAE30\uB85C \uC904\uC5EC \uC608\uC804\uACFC \uAC19\uC774 \uD310 \uAC00\uC6B4\uB370\u00b7\uBC11\uC5D0 \uB193\uC778\uB2E4.
329
+ */
330
+ const navRail = document.createElement("div");
331
+ navRail.className = "pdf-nav-rail";
332
+ const pageCluster = document.createElement("div");
333
+ pageCluster.className = "pdf-cluster pdf-cluster-page";
334
+ const pageBtn = document.createElement("button");
335
+ pageBtn.type = "button";
336
+ pageBtn.className = "pdf-page";
337
+ pageBtn.title = "\uCABD \uBC88\uD638\uB85C \uC774\uB3D9";
338
+ pageCluster.appendChild(pageBtn);
339
+ /*
340
+ * \uCABD\uC218\uB97C \uB20C\uB7EC **\uBC88\uD638\uB85C \uAC74\uB108\uB6F4\uB2E4.** \uC774\uAC8C \uC5C6\uC73C\uBA74 30\uCABD\uC9DC\uB9AC \uC790\uB8CC\uC5D0\uC11C \uB4A4\uB85C \uAC00\uB824\uBA74
341
+ * \uD654\uC0B4\uD45C\uB97C 30\uBC88 \uB20C\uB7EC\uC57C \uD55C\uB2E4 \u2014 \uD398\uC774\uC9C0 \uB118\uAE40 \uBAA8\uB378\uC758 \uAC00\uC7A5 \uC544\uD508 \uC9C0\uC810\uC774\uB2E4.
342
+ */
343
+ const pageInput = document.createElement("input");
344
+ pageInput.type = "text";
345
+ pageInput.inputMode = "numeric";
346
+ pageInput.className = "pdf-page-input";
347
+ pageInput.setAttribute("aria-label", "\uCABD \uBC88\uD638");
348
+ pageInput.style.display = "none";
349
+ pageCluster.appendChild(pageInput);
360
350
  let currentPage = 1;
361
351
  let totalPages = 0;
362
352
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
363
353
  let pdfDoc = null;
364
354
  let rendering = false;
355
+ let renderQueued = false;
356
+ /** 쪽수 표시와 화살표 비활성 상태를 현재 페이지에 맞춘다. */
357
+ const syncPageUi = () => {
358
+ pageBtn.textContent = `${currentPage} / ${totalPages}`;
359
+ prevBtn.disabled = currentPage <= 1;
360
+ nextBtn.disabled = currentPage >= totalPages;
361
+ };
362
+ const goToPage = (next) => {
363
+ const clamped = Math.min(totalPages, Math.max(1, next));
364
+ if (clamped === currentPage)
365
+ return;
366
+ currentPage = clamped;
367
+ syncPageUi();
368
+ renderPage();
369
+ };
370
+ prevBtn.addEventListener("click", () => goToPage(currentPage - 1));
371
+ nextBtn.addEventListener("click", () => goToPage(currentPage + 1));
372
+ const closePageInput = () => {
373
+ pageInput.style.display = "none";
374
+ pageBtn.style.display = "";
375
+ };
376
+ pageBtn.addEventListener("click", () => {
377
+ pageInput.value = String(currentPage);
378
+ pageBtn.style.display = "none";
379
+ pageInput.style.display = "";
380
+ pageInput.focus();
381
+ pageInput.select();
382
+ });
383
+ pageInput.addEventListener("keydown", (e) => {
384
+ // tiptap 이 단축키를 가로채면 숫자가 안 들어간다(이름칸과 같은 이유).
385
+ e.stopPropagation();
386
+ if (e.key === "Enter") {
387
+ e.preventDefault();
388
+ const parsed = Number.parseInt(pageInput.value, 10);
389
+ if (Number.isFinite(parsed))
390
+ goToPage(parsed);
391
+ closePageInput();
392
+ }
393
+ else if (e.key === "Escape") {
394
+ e.preventDefault();
395
+ closePageInput();
396
+ }
397
+ });
398
+ pageInput.addEventListener("blur", closePageInput);
399
+ /*
400
+ * 화살표 키는 **조작부 안에 포커스가 있을 때만** 받는다. 블록 전체에서 가로채면
401
+ * 편집 중 이 블록을 지나 위아래로 빠져나가는 길이 막힌다(atom 노드라 화살표가
402
+ * ProseMirror 의 탈출 경로다). 넘김 버튼을 한 번 누르면 그때부터 키가 듣는다.
403
+ */
404
+ overlay.addEventListener("keydown", (e) => {
405
+ if (e.target instanceof HTMLInputElement)
406
+ return;
407
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
408
+ return;
409
+ e.preventDefault();
410
+ e.stopPropagation();
411
+ goToPage(currentPage + (e.key === "ArrowLeft" ? -1 : 1));
412
+ });
413
+ /*
414
+ * ⚠️ **그리는 중에 들어온 요청을 버리면 안 된다.** 예전엔 `if (rendering) return` 으로
415
+ * 그냥 흘려보냈는데, 폭 프리셋이나 창 크기 변경이 **직전 렌더가 끝나기 전에** 오면
416
+ * 그 요청이 통째로 사라져 캔버스가 옛 크기에 머문다(다음 렌더까지 아무도 다시
417
+ * 부르지 않는다). 겹치면 한 번 예약해 뒀다가 끝나고 이어서 그린다.
418
+ * pdf.js 는 같은 캔버스에 렌더가 겹치면 던지므로 **직렬화 자체는 유지**한다.
419
+ */
365
420
  async function renderPage() {
366
- if (!pdfDoc || rendering || destroyed)
421
+ if (!pdfDoc || destroyed)
367
422
  return;
423
+ if (rendering) {
424
+ renderQueued = true;
425
+ return;
426
+ }
368
427
  rendering = true;
369
428
  try {
370
429
  const pageObj = await pdfDoc.getPage(currentPage);
371
430
  if (destroyed)
372
431
  return;
373
432
  const unscaledViewport = pageObj.getViewport({ scale: 1 });
374
- const availableWidth = contentArea.clientWidth;
375
- if (availableWidth <= 0) {
376
- rendering = false;
433
+ const availableWidth = frame.clientWidth;
434
+ if (availableWidth <= 0)
377
435
  return;
378
- }
436
+ /*
437
+ * 폭에만 맞춘다 — **높이는 제한하지 않는다.**
438
+ *
439
+ * ⚠️ 한때 `min(폭맞춤, 0.78vh)` 로 한 장이 화면에 들어가게 했다가 되돌렸다.
440
+ * A4 는 세로가 길어서 웬만한 창에서는 **높이 쪽이 항상 먼저 걸리고**, 그러면
441
+ * 폭 프리셋 50%/75%/100% 가 **전부 같은 크기로 그려진다**(실측: 50% 도 100% 도
442
+ * 캔버스 673px). 저자가 고른 폭이 무의미해지는 건 얻는 것보다 잃는 게 크다.
443
+ * 판이 화면보다 길 때 조작부가 따라오게 하는 건 CSS(`.pdf-nav-rail`)가 맡는다.
444
+ */
379
445
  const scale = availableWidth / unscaledViewport.width;
380
446
  const viewport = pageObj.getViewport({ scale });
381
447
  const dpr = window.devicePixelRatio || 1;
@@ -384,10 +450,8 @@ export const PdfBlock = TiptapNode.create({
384
450
  canvas.style.width = `${viewport.width}px`;
385
451
  canvas.style.height = `${viewport.height}px`;
386
452
  const ctx = canvas.getContext("2d");
387
- if (!ctx) {
388
- rendering = false;
453
+ if (!ctx)
389
454
  return;
390
- }
391
455
  ctx.resetTransform();
392
456
  ctx.clearRect(0, 0, canvas.width, canvas.height);
393
457
  ctx.scale(dpr, dpr);
@@ -396,10 +460,9 @@ export const PdfBlock = TiptapNode.create({
396
460
  finally {
397
461
  rendering = false;
398
462
  }
399
- if (navDiv) {
400
- const pageSpan = navDiv.querySelector(".page-counter");
401
- if (pageSpan)
402
- pageSpan.textContent = `${currentPage} / ${totalPages}`;
463
+ if (renderQueued && !destroyed) {
464
+ renderQueued = false;
465
+ await renderPage();
403
466
  }
404
467
  }
405
468
  async function loadPdf(src) {
@@ -412,60 +475,42 @@ export const PdfBlock = TiptapNode.create({
412
475
  if (destroyed)
413
476
  return;
414
477
  totalPages = pdfDoc.numPages;
415
- loadingDiv.style.display = "none";
478
+ /*
479
+ * 배율 프리셋의 기준점 — **1쪽의 원본 폭**. pdf.js 는 배율 1 에서
480
+ * 1 PDF 단위 = 1 CSS px 이므로 이 값이 곧 "원본 크기"다.
481
+ * 쪽마다 크기가 다른 문서가 있지만, 저자가 블록 크기를 고르는 기준은 하나여야
482
+ * 하므로 1쪽으로 고정한다(넘길 때마다 블록이 들썩이면 안 된다).
483
+ */
484
+ const firstPage = await pdfDoc.getPage(1);
485
+ if (destroyed)
486
+ return;
487
+ naturalWidth = firstPage.getViewport({ scale: 1 }).width;
488
+ for (const { el } of presetButtons)
489
+ el.disabled = false;
490
+ syncPresets(currentNode.attrs.width ?? null);
491
+ statusDiv.style.display = "none";
416
492
  canvas.style.display = "";
493
+ // \uD55C \uC7A5\uC9DC\uB9AC\uC5D4 \uB118\uAE40 \uC870\uC791\uC744 \uC544\uC608 \uBD99\uC774\uC9C0 \uC54A\uB294\uB2E4 \u2014 \uB204\uB97C \uC218 \uC5C6\uB294 \uBC84\uD2BC\uC740 \uC18C\uC74C\uC774\uB2E4.
417
494
  if (totalPages > 1) {
418
- navDiv = document.createElement("div");
419
- navDiv.className =
420
- "flex items-center justify-center gap-4 px-3 py-1 border-t border-border";
421
- navDiv.style.background = "var(--muted)";
422
- const prevBtn = document.createElement("button");
423
- prevBtn.type = "button";
424
- prevBtn.className =
425
- "p-1.5 rounded hover:bg-muted transition-colors disabled:opacity-30 text-foreground";
426
- prevBtn.innerHTML =
427
- '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>';
428
- prevBtn.addEventListener("click", () => {
429
- if (currentPage > 1) {
430
- currentPage--;
431
- prevBtn.disabled = currentPage <= 1;
432
- nextBtn.disabled = currentPage >= totalPages;
433
- renderPage();
434
- }
435
- });
436
- navDiv.appendChild(prevBtn);
437
- const pageSpan = document.createElement("span");
438
- pageSpan.className = "text-sm tabular-nums page-counter select-none";
439
- pageSpan.style.userSelect = "none";
440
- pageSpan.textContent = `1 / ${totalPages}`;
441
- navDiv.appendChild(pageSpan);
442
- const nextBtn = document.createElement("button");
443
- nextBtn.type = "button";
444
- nextBtn.className =
445
- "p-1.5 rounded hover:bg-muted transition-colors disabled:opacity-30 text-foreground";
446
- nextBtn.innerHTML =
447
- '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>';
448
- nextBtn.addEventListener("click", () => {
449
- if (currentPage < totalPages) {
450
- currentPage++;
451
- prevBtn.disabled = currentPage <= 1;
452
- nextBtn.disabled = currentPage >= totalPages;
453
- renderPage();
454
- }
455
- });
456
- navDiv.appendChild(nextBtn);
457
- wrapper.appendChild(navDiv);
495
+ navRail.appendChild(prevBtn);
496
+ navRail.appendChild(nextBtn);
497
+ navRail.appendChild(pageCluster);
498
+ overlay.appendChild(navRail);
499
+ syncPageUi();
458
500
  }
459
501
  renderPage();
502
+ /*
503
+ * \u26A0\uFE0F \uAD00\uCC30 \uB300\uC0C1\uC740 `dom` \uC774\uC9C0 \uD310\uC774 \uC544\uB2C8\uB2E4. \uD310\uC744 \uBCF4\uBA74 \uCE94\uBC84\uC2A4 \uB192\uC774\uAC00 \uBC14\uB014 \uB54C\uB9C8\uB2E4
504
+ * \uB2E4\uC2DC \uBD88\uB824 \uB418\uBA39\uC784\uC774 \uC0DD\uAE34\uB2E4 \u2014 \uD3ED\uC740 `dom` \uC5D0\uC11C\uB9CC \uBC14\uB010\uB2E4.
505
+ */
460
506
  resizeObserver = new ResizeObserver(() => {
461
507
  clearTimeout(resizeTimeout);
462
508
  resizeTimeout = setTimeout(renderPage, 100);
463
509
  });
464
- resizeObserver.observe(contentArea);
510
+ resizeObserver.observe(dom);
465
511
  }
466
512
  catch {
467
- loadingDiv.innerHTML =
468
- '<p class="text-sm text-muted-foreground">PDF\uB97C \uBD88\uB7EC\uC62C \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.</p>';
513
+ statusDiv.textContent = "PDF\uB97C \uBD88\uB7EC\uC62C \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.";
469
514
  }
470
515
  }
471
516
  // URL 해결 후 PDF 로드
@@ -497,10 +542,8 @@ export const PdfBlock = TiptapNode.create({
497
542
  resolver(node.attrs.fileId)
498
543
  .then((result) => {
499
544
  if (result.name) {
500
- // 다운로드 파일명은 항상 실제 파일명. 표시는 `label` 이 있으면 그쪽이 이긴다.
501
- resolvedName = result.name;
545
+ // \uB2E4\uC6B4\uB85C\uB4DC \uD30C\uC77C\uBA85\uC740 \uD56D\uC0C1 \uC2E4\uC81C \uD30C\uC77C\uBA85\uC774\uB2E4.
502
546
  downloadLink.setAttribute("download", result.name);
503
- syncName();
504
547
  }
505
548
  })
506
549
  .catch(() => { });
@@ -513,12 +556,24 @@ export const PdfBlock = TiptapNode.create({
513
556
  if (updatedNode.type.name !== "pdfBlock")
514
557
  return false;
515
558
  const newWidth = updatedNode.attrs.width;
516
- if (newWidth !== currentNode.attrs.width) {
559
+ const changed = newWidth !== currentNode.attrs.width;
560
+ if (changed)
517
561
  dom.style.width = newWidth || "";
518
- }
519
562
  currentNode = updatedNode;
520
563
  syncPresets(newWidth ?? null);
521
- syncName();
564
+ /*
565
+ * ⚠️ **크기가 바뀌면 여기서 직접 다시 그린다.** 예전엔 `ResizeObserver` 가
566
+ * 알아서 잡아 주길 기대했는데, 그건 크기를 **누가 바꿨는지 모를 때**를 위한
567
+ * 장치다. 프리셋 클릭·드래그 커밋은 새 폭을 이미 알고 들어오는 경로이므로
568
+ * 관찰자를 한 바퀴 돌 이유가 없다.
569
+ *
570
+ * 실제로 이것 때문에 프리셋이 조용히 반쪽만 동작했다 — 블록 폭은 595px 로
571
+ * 줄었는데 캔버스는 1376px 그대로였다. (자동화 탭은 `visibilityState: hidden`
572
+ * 이라 `ResizeObserver` 콜백이 아예 안 와서 오래 못 잡은 증상이기도 하다.)
573
+ * `frame.clientWidth` 는 읽는 순간 레이아웃을 강제하므로 새 폭이 바로 잡힌다.
574
+ */
575
+ if (changed)
576
+ renderPage();
522
577
  return true;
523
578
  },
524
579
  // 이름 입력칸 위의 이벤트는 ProseMirror 가 가로채면 안 된다(CardBlock 과 같은 방식).
@@ -526,8 +581,15 @@ export const PdfBlock = TiptapNode.create({
526
581
  const target = event.target;
527
582
  return target instanceof Element && !!target.closest("[data-pdf-control]");
528
583
  },
529
- selectNode: () => { },
530
- deselectNode: () => { },
584
+ /*
585
+ * ⚠️ 여기 `selectNode`/`deselectNode` 를 빈 함수로 두면 안 된다.
586
+ *
587
+ * ProseMirror 는 이 훅이 **있으면** "노드뷰가 알아서 표시하겠다"로 보고
588
+ * `ProseMirror-selectednode` 클래스를 **아예 붙이지 않는다.** 빈 함수라도 마찬가지라,
589
+ * 선택 표시를 CSS 로 아무리 써도 매칭될 선택자가 생기지 않는다 — 편집 중 PDF 를
590
+ * 클릭해도 아무 표시가 없던 이유가 이것이었다(사용자 지적).
591
+ * 훅을 아예 두지 않으면 클래스 관리는 ProseMirror 가 한다.
592
+ */
531
593
  destroy: () => {
532
594
  destroyed = true;
533
595
  clearTimeout(resizeTimeout);