Skip to content

Commit 23650cb

Browse files
authored
Merge pull request #226 from znz/clipboard-api
COPY ボタンを Clipboard API 化する
2 parents 830d389 + 2e09357 commit 23650cb

2 files changed

Lines changed: 128 additions & 37 deletions

File tree

test/js/test_script.mjs

Lines changed: 97 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,28 @@ function assert(cond, message) {
1515
}
1616

1717
// --- minimal fake DOM -------------------------------------------------
18-
// script.js only assigns elem.innerHTML across elements and to a fresh
19-
// textarea, so innerHTML is modeled as an opaque snapshot (string or
20-
// {ownText, children}) instead of parsed HTML.
18+
// script.js only assigns elem.innerHTML across elements, so innerHTML is
19+
// modeled as an opaque snapshot (string or {ownText, children}) instead of
20+
// parsed HTML.
2121
class FakeElement {
2222
constructor(tag, className = '', text = '') {
2323
this.tagName = tag.toUpperCase()
2424
this.className = className
2525
this.ownText = text
2626
this.children = []
2727
this.onclick = null
28+
this.value = ''
29+
const classes = new Set()
30+
this.classList = {
31+
add(c) { classes.add(c) },
32+
remove(c) { classes.delete(c) },
33+
contains(c) { return classes.has(c) },
34+
}
2835
}
2936
setAttribute(name, value) {
3037
if (name === 'class') this.className = value
3138
}
39+
select() {}
3240
appendChild(child) {
3341
this.children.push(child)
3442
return child
@@ -84,6 +92,7 @@ class FakeElement {
8492
function makeDocument(elements) {
8593
return {
8694
_elements: elements,
95+
body: new FakeElement('body'),
8796
createElement(tag) { return new FakeElement(tag) },
8897
getElementsByClassName(name) {
8998
return this._elements.filter(e => e.className.split(' ').indexOf(name) >= 0)
@@ -99,44 +108,72 @@ function makeDocument(elements) {
99108
const here = import.meta.url.replace(/^file:\/\//, '').replace(/\/[^/]*$/, '')
100109
const source = std.loadFile(here + '/../../theme/default/script.js')
101110

102-
function runOnload(elements) {
111+
function runOnload(elements, navigatorFake) {
103112
globalThis.document = makeDocument(elements)
104113
globalThis.window = { setTimeout() { return 0 } }
114+
globalThis.navigator = navigatorFake || {}
105115
;(0, eval)(source)
106116
globalThis.window.onload()
107117
return elements
108118
}
109119

120+
// Clipboard API を捕捉する navigator フェイク
121+
function clipboardSpy() {
122+
const written = []
123+
return {
124+
written,
125+
navigator: {
126+
clipboard: {
127+
writeText(text) {
128+
written.push(text)
129+
return Promise.resolve()
130+
},
131+
},
132+
},
133+
}
134+
}
135+
136+
// クリックの Promise 連鎖(writeClipboard().then)を流すための待ち
137+
async function settle() {
138+
await 0
139+
await 0
140+
}
141+
110142
// A ruby sample keeps getting the COPY button (regression)
111143
{
144+
const spy = clipboardSpy()
112145
const pre = new FakeElement('pre', 'highlight ruby')
113146
pre.appendChild(new FakeElement('code', '', 'puts 1\n'))
114-
runOnload([pre])
115-
assert(pre.childByClass('highlight__copy-button') !== null,
116-
'pre.highlight.ruby gets a COPY button')
117-
assert(pre.firstChild === pre.childByClass('highlight__copy-button'),
118-
'COPY button is prepended as the first child')
119-
const copyText = pre.childByClass('highlight__copy-text')
120-
assert(copyText !== null && copyText.textContent === 'puts 1\n',
121-
'copy text preserves the sample code')
147+
runOnload([pre], spy.navigator)
148+
const btn = pre.childByClass('highlight__copy-button')
149+
assert(btn !== null, 'pre.highlight.ruby gets a COPY button')
150+
assert(pre.firstChild === btn, 'COPY button is prepended as the first child')
151+
btn.onclick()
152+
await settle()
153+
assert(spy.written.length === 1 && spy.written[0] === 'puts 1\n',
154+
'clicking COPY writes the sample code via the Clipboard API')
155+
assert(btn.classList.contains('copied'),
156+
'the copied indicator is shown after a successful write')
122157
}
123158

124159
// A plain <pre> (no language fence, //emlist{ origin) also gets the button
125160
{
161+
const spy = clipboardSpy()
126162
const pre = new FakeElement('pre', '', 'ary = []\n')
127-
runOnload([pre])
128-
assert(pre.childByClass('highlight__copy-button') !== null,
129-
'plain <pre> without highlight class gets a COPY button')
130-
const copyText = pre.childByClass('highlight__copy-text')
131-
assert(copyText !== null && copyText.textContent === 'ary = []\n',
132-
'plain <pre> copy text preserves the block content')
163+
runOnload([pre], spy.navigator)
164+
const btn = pre.childByClass('highlight__copy-button')
165+
assert(btn !== null, 'plain <pre> without highlight class gets a COPY button')
166+
btn.onclick()
167+
await settle()
168+
assert(spy.written[0] === 'ary = []\n',
169+
'plain <pre> copy writes the block content')
133170
}
134171

135172
// A non-ruby language fence keeps the button
136173
{
137174
const pre = new FakeElement('pre', 'highlight c')
138175
pre.appendChild(new FakeElement('code', '', 'VALUE v;\n'))
139-
runOnload([pre])
176+
runOnload([pre], clipboardSpy().navigator)
140177
assert(pre.childByClass('highlight__copy-button') !== null,
141178
'pre.highlight.c gets a COPY button')
142179
}
@@ -145,31 +182,63 @@ function runOnload(elements) {
145182
{
146183
const div = new FakeElement('div', 'highlight')
147184
const pre = new FakeElement('pre', '')
148-
runOnload([div, pre])
185+
runOnload([div, pre], clipboardSpy().navigator)
149186
assert(div.childByClass('highlight__copy-button') === null,
150187
'non-pre element does not get a COPY button')
151188
}
152189

153190
// The caption is excluded from the copied text
154191
{
192+
const spy = clipboardSpy()
155193
const pre = new FakeElement('pre', 'highlight ruby')
156194
pre.appendChild(new FakeElement('span', 'caption', '例'))
157195
pre.appendChild(new FakeElement('code', '', 'p 42\n'))
158-
runOnload([pre])
159-
const copyText = pre.childByClass('highlight__copy-text')
160-
assert(copyText !== null && copyText.textContent === 'p 42\n',
161-
'caption text is excluded from the copy text')
196+
runOnload([pre], spy.navigator)
197+
pre.childByClass('highlight__copy-button').onclick()
198+
await settle()
199+
assert(spy.written[0] === 'p 42\n',
200+
'caption text is excluded from the copied text')
162201
assert(pre.childByClass('caption') !== null,
163202
'caption itself stays visible in the sample')
164203
}
165204

166205
// Leading blank lines are stripped, trailing ones squeezed to one
167206
{
207+
const spy = clipboardSpy()
168208
const pre = new FakeElement('pre', '', '\n\nputs 1\n\n\n')
169-
runOnload([pre])
170-
const copyText = pre.childByClass('highlight__copy-text')
171-
assert(copyText !== null && copyText.textContent === 'puts 1\n',
172-
'copy text is trimmed (leading newlines dropped, trailing squeezed)')
209+
runOnload([pre], spy.navigator)
210+
pre.childByClass('highlight__copy-button').onclick()
211+
await settle()
212+
assert(spy.written[0] === 'puts 1\n',
213+
'copied text is trimmed (leading newlines dropped, trailing squeezed)')
214+
}
215+
216+
// Clipboard API が無い環境では textarea + execCommand にフォールバックする
217+
{
218+
const pre = new FakeElement('pre', '', 'fallback code\n')
219+
const elements = [pre]
220+
globalThis.document = makeDocument(elements)
221+
let captured = null
222+
globalThis.document.execCommand = function (command) {
223+
if (command === 'copy') {
224+
const ta = globalThis.document.body.children.find(c => c.tagName === 'TEXTAREA')
225+
captured = ta ? ta.value : null
226+
}
227+
return true
228+
}
229+
globalThis.window = { setTimeout() { return 0 } }
230+
globalThis.navigator = {} // clipboard なし
231+
;(0, eval)(source)
232+
globalThis.window.onload()
233+
const btn = pre.childByClass('highlight__copy-button')
234+
btn.onclick()
235+
await settle()
236+
assert(captured === 'fallback code\n',
237+
'without the Clipboard API the text is copied via textarea + execCommand')
238+
assert(globalThis.document.body.children.length === 0,
239+
'the fallback textarea is removed after copying')
240+
assert(btn.classList.contains('copied'),
241+
'the copied indicator is shown for the fallback path too')
173242
}
174243

175244
if (failures > 0) {

theme/default/script.js

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,27 @@
11
(function() {
2+
// クリップボードへのコピー。Clipboard API が使えない環境
3+
// (非セキュアコンテキストや古いブラウザ)では従来の
4+
// textarea + execCommand にフォールバックする
5+
function writeClipboard(text) {
6+
if (navigator.clipboard && navigator.clipboard.writeText) {
7+
return navigator.clipboard.writeText(text)
8+
}
9+
return new Promise(function(resolve, reject) {
10+
const textarea = document.createElement('textarea')
11+
textarea.setAttribute('class', 'highlight__copy-text')
12+
textarea.value = text
13+
document.body.appendChild(textarea)
14+
textarea.select()
15+
const ok = document.execCommand('copy')
16+
document.body.removeChild(textarea)
17+
if (ok) {
18+
resolve()
19+
} else {
20+
reject(new Error('copy command failed'))
21+
}
22+
})
23+
}
24+
225
window.onload = function() {
326
// 言語指定なしのコードブロックは class を持たない素の <pre> になるため、
427
// highlight クラスではなく pre 要素全体に COPY ボタンを付ける
@@ -13,22 +36,21 @@
1336
const caption = tempDiv.getElementsByClassName("caption")[0]
1437
if (caption) tempDiv.removeChild(caption)
1538

16-
// textarea for preserving the copy text
17-
const copyText = document.createElement('textarea')
18-
copyText.setAttribute('class', 'highlight__copy-text')
19-
copyText.innerHTML = tempDiv.textContent.replace(/^\n+/, "").replace(/\n{2,}$/, "\n")
20-
elem.appendChild(copyText)
39+
// RUN ボタンでサンプルを編集しても COPY は元のテキストを保持する
40+
const text = tempDiv.textContent.replace(/^\n+/, "").replace(/\n{2,}$/, "\n")
2141

2242
// COPY button
2343
const btn = document.createElement('span')
2444
btn.setAttribute('class', 'highlight__copy-button')
2545
elem.insertBefore(btn, elem.firstChild)
2646

2747
btn.onclick = function(){
28-
copyText.select()
29-
document.execCommand("copy")
30-
btn.classList.add("copied")
31-
window.setTimeout(function() { btn.classList.remove("copied") }, 1000)
48+
writeClipboard(text).then(function() {
49+
btn.classList.add("copied")
50+
window.setTimeout(function() { btn.classList.remove("copied") }, 1000)
51+
}).catch(function() {
52+
// コピー失敗時は従来どおり何も表示しない
53+
})
3254
}
3355
}
3456
)

0 commit comments

Comments
 (0)