@@ -37,24 +37,38 @@ class FakeElement {
3737 if ( name === 'class' ) this . className = value
3838 }
3939 select ( ) { }
40+ // 実 DOM 同様、挿入時は元の親から外して付け替える(parentNode も追跡)
4041 appendChild ( child ) {
42+ if ( child . parentNode ) child . parentNode . removeChild ( child )
43+ child . parentNode = this
4144 this . children . push ( child )
4245 return child
4346 }
4447 insertBefore ( child , ref ) {
48+ if ( child . parentNode ) child . parentNode . removeChild ( child )
49+ child . parentNode = this
4550 const i = this . children . indexOf ( ref )
4651 if ( ref == null || i < 0 ) this . children . push ( child )
4752 else this . children . splice ( i , 0 , child )
4853 return child
4954 }
5055 removeChild ( child ) {
5156 const i = this . children . indexOf ( child )
52- if ( i >= 0 ) this . children . splice ( i , 1 )
57+ if ( i >= 0 ) {
58+ this . children . splice ( i , 1 )
59+ child . parentNode = null
60+ }
5361 return child
5462 }
5563 get firstChild ( ) {
5664 return this . children . length > 0 ? this . children [ 0 ] : null
5765 }
66+ get previousElementSibling ( ) {
67+ if ( ! this . parentNode ) return null
68+ const siblings = this . parentNode . children
69+ const i = siblings . indexOf ( this )
70+ return i > 0 ? siblings [ i - 1 ] : null
71+ }
5872 get textContent ( ) {
5973 return this . ownText + this . children . map ( c => c . textContent ) . join ( '' )
6074 }
@@ -108,13 +122,17 @@ function makeDocument(elements) {
108122const here = import . meta. url . replace ( / ^ f i l e : \/ \/ / , '' ) . replace ( / \/ [ ^ / ] * $ / , '' )
109123const source = std . loadFile ( here + '/../../theme/default/script.js' )
110124
125+ // script.js は pre の直前(親の子リスト)にツールバーを差し込むので、
126+ // テスト対象の要素は root(body 相当)にぶら下げて親を持たせる
111127function runOnload ( elements , navigatorFake ) {
128+ const root = new FakeElement ( 'div' )
129+ elements . forEach ( ( e ) => root . appendChild ( e ) )
112130 globalThis . document = makeDocument ( elements )
113131 globalThis . window = { setTimeout ( ) { return 0 } }
114132 globalThis . navigator = navigatorFake || { }
115133 ; ( 0 , eval ) ( source )
116134 globalThis . window . onload ( )
117- return elements
135+ return root
118136}
119137
120138// Clipboard API を捕捉する navigator フェイク
@@ -139,15 +157,35 @@ async function settle() {
139157 await 0
140158}
141159
160+ // pre の直前のツールバー行 → ボタン置き場 → COPY を辿るヘルパー
161+ function toolbarOf ( elem ) {
162+ const prev = elem . previousElementSibling
163+ if ( prev && prev . className . split ( ' ' ) . indexOf ( 'highlight__toolbar' ) >= 0 ) return prev
164+ return null
165+ }
166+ function buttonGroupOf ( elem ) {
167+ const toolbar = toolbarOf ( elem )
168+ return toolbar && toolbar . childByClass ( 'highlight__button-group' )
169+ }
170+ function copyButtonOf ( elem ) {
171+ const group = buttonGroupOf ( elem )
172+ return group && group . childByClass ( 'highlight__copy-button' )
173+ }
174+
142175// A ruby sample keeps getting the COPY button (regression)
143176{
144177 const spy = clipboardSpy ( )
145178 const pre = new FakeElement ( 'pre' , 'highlight ruby' )
146179 pre . appendChild ( new FakeElement ( 'code' , '' , 'puts 1\n' ) )
147180 runOnload ( [ pre ] , spy . navigator )
148- const btn = pre . childByClass ( 'highlight__copy-button' )
181+ const btn = copyButtonOf ( pre )
149182 assert ( btn !== null , 'pre.highlight.ruby gets a COPY button' )
150- assert ( pre . firstChild === btn , 'COPY button is prepended as the first child' )
183+ assert ( toolbarOf ( pre ) !== null ,
184+ 'a toolbar row is inserted just before the pre (outside of it)' )
185+ assert ( pre . childByClass ( 'highlight__button-group' ) === null ,
186+ 'no button container is injected inside the pre itself' )
187+ assert ( buttonGroupOf ( pre ) . firstChild === btn ,
188+ 'COPY button lives inside the toolbar button group' )
151189 btn . onclick ( )
152190 await settle ( )
153191 assert ( spy . written . length === 1 && spy . written [ 0 ] === 'puts 1\n' ,
@@ -161,7 +199,7 @@ async function settle() {
161199 const spy = clipboardSpy ( )
162200 const pre = new FakeElement ( 'pre' , '' , 'ary = []\n' )
163201 runOnload ( [ pre ] , spy . navigator )
164- const btn = pre . childByClass ( 'highlight__copy-button' )
202+ const btn = copyButtonOf ( pre )
165203 assert ( btn !== null , 'plain <pre> without highlight class gets a COPY button' )
166204 btn . onclick ( )
167205 await settle ( )
@@ -174,7 +212,7 @@ async function settle() {
174212 const pre = new FakeElement ( 'pre' , 'highlight c' )
175213 pre . appendChild ( new FakeElement ( 'code' , '' , 'VALUE v;\n' ) )
176214 runOnload ( [ pre ] , clipboardSpy ( ) . navigator )
177- assert ( pre . childByClass ( 'highlight__copy-button' ) !== null ,
215+ assert ( copyButtonOf ( pre ) !== null ,
178216 'pre.highlight.c gets a COPY button' )
179217}
180218
@@ -183,7 +221,7 @@ async function settle() {
183221 const div = new FakeElement ( 'div' , 'highlight' )
184222 const pre = new FakeElement ( 'pre' , '' )
185223 runOnload ( [ div , pre ] , clipboardSpy ( ) . navigator )
186- assert ( div . childByClass ( 'highlight__copy-button' ) === null ,
224+ assert ( copyButtonOf ( div ) === null ,
187225 'non-pre element does not get a COPY button' )
188226}
189227
@@ -194,7 +232,7 @@ async function settle() {
194232 pre . appendChild ( new FakeElement ( 'span' , 'caption' , '例' ) )
195233 pre . appendChild ( new FakeElement ( 'code' , '' , 'p 42\n' ) )
196234 runOnload ( [ pre ] , spy . navigator )
197- pre . childByClass ( 'highlight__copy-button' ) . onclick ( )
235+ copyButtonOf ( pre ) . onclick ( )
198236 await settle ( )
199237 assert ( spy . written [ 0 ] === 'p 42\n' ,
200238 'caption text is excluded from the copied text' )
@@ -207,25 +245,48 @@ async function settle() {
207245 const spy = clipboardSpy ( )
208246 const pre = new FakeElement ( 'pre' , '' , '\n\nputs 1\n\n\n' )
209247 runOnload ( [ pre ] , spy . navigator )
210- pre . childByClass ( 'highlight__copy-button' ) . onclick ( )
248+ copyButtonOf ( pre ) . onclick ( )
211249 await settle ( )
212250 assert ( spy . written [ 0 ] === 'puts 1\n' ,
213251 'copied text is trimmed (leading newlines dropped, trailing squeezed)' )
214252}
215253
254+ // pre の直前に caption(タブ)があれば、ツールバーの左端に取り込まれる
255+ {
256+ const caption = new FakeElement ( 'span' , 'caption' , '例' )
257+ const pre = new FakeElement ( 'pre' , 'highlight ruby' )
258+ pre . appendChild ( new FakeElement ( 'code' , '' , 'p 42\n' ) )
259+ const root = runOnload ( [ caption , pre ] , clipboardSpy ( ) . navigator )
260+ const toolbar = toolbarOf ( pre )
261+ assert ( toolbar !== null && toolbar . firstChild === caption ,
262+ 'a sibling caption is moved to the left edge of the toolbar' )
263+ assert ( root . children . indexOf ( caption ) < 0 ,
264+ 'the caption is no longer a direct sibling of the pre' )
265+ assert ( toolbar . childByClass ( 'highlight__button-group' ) !== null ,
266+ 'the button group sits in the same toolbar row as the caption' )
267+ }
268+
216269// RUN 出力など、後から生成される pre にもボタンを付けられる公開フック。
217270// getText はクリック時に評価されるので、内容が変わる要素にも使える
218271{
219272 const spy = clipboardSpy ( )
220273 const pre = new FakeElement ( 'pre' , '' )
221- runOnload ( [ pre ] , spy . navigator )
274+ const root = runOnload ( [ pre ] , spy . navigator )
222275 assert ( typeof globalThis . window . ruremaAddCopyButton === 'function' ,
223276 'window.ruremaAddCopyButton is exposed for dynamically created pre' )
277+ // 実際の run.js と同様、DOM に挿入してからフックを呼ぶ
224278 const output = new FakeElement ( 'pre' , 'highlight__run-output' )
279+ root . appendChild ( output )
225280 let current = 'first output\n'
226281 const btn = globalThis . window . ruremaAddCopyButton ( output , ( ) => current )
227- assert ( output . firstChild === btn ,
228- 'the hook prepends a COPY button to the given element' )
282+ assert ( toolbarOf ( output ) !== null &&
283+ buttonGroupOf ( output ) . childByClass ( 'highlight__copy-button' ) === btn ,
284+ 'the hook inserts a toolbar with the COPY button before the element' )
285+ // 既にツールバーがある要素にもう一度呼んでも、ツールバーは1つのまま再利用される
286+ globalThis . window . ruremaAddCopyButton ( output , ( ) => current )
287+ assert ( root . children . filter (
288+ c => c . className === 'highlight__toolbar' ) . length === 2 ,
289+ 'an existing toolbar is reused (one for the sample, one for the output)' )
229290 btn . onclick ( )
230291 await settle ( )
231292 current = 'second output\n'
@@ -239,6 +300,8 @@ async function settle() {
239300// Clipboard API が無い環境では textarea + execCommand にフォールバックする
240301{
241302 const pre = new FakeElement ( 'pre' , '' , 'fallback code\n' )
303+ const root = new FakeElement ( 'div' )
304+ root . appendChild ( pre )
242305 const elements = [ pre ]
243306 globalThis . document = makeDocument ( elements )
244307 let captured = null
@@ -253,7 +316,7 @@ async function settle() {
253316 globalThis . navigator = { } // clipboard なし
254317 ; ( 0 , eval ) ( source )
255318 globalThis . window . onload ( )
256- const btn = pre . childByClass ( 'highlight__copy-button' )
319+ const btn = copyButtonOf ( pre )
257320 btn . onclick ( )
258321 await settle ( )
259322 assert ( captured === 'fallback code\n' ,
0 commit comments