@@ -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 : ( ) => {
0 commit comments