Skip to content

Commit 3169ece

Browse files
committed
RUN/COPY ボタンの UI 改善(レビュー指摘対応)
- RUN/COPY を1つの float コンテナ(.highlight__button-group)にまとめ、 ボタン間・周囲の余白で pointer と text のカーソル形状がちらつかない ようにする(隙間のカーソルと選択不可はコンテナが引き受ける) - 編集可能にしたときに先頭へ空行が入るのを解消。コンパイラが <code> 直後に置く改行をプレーンテキスト化時に除去(editableText)し、編集時の code を inline-block + width:100%(float と同じ行に入れずボタンの下に 落ちる)から block に変更 - 貼り付けで改行が <br>/<div> になって前の行にくっつくのを解消。 contentEditable=plaintext-only を優先し、未対応ブラウザは paste で text/plain をキャレット位置に挿入するフォールバック
1 parent c5cc6e9 commit 3169ece

5 files changed

Lines changed: 129 additions & 29 deletions

File tree

test/js/test_run.mjs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// importing either file here never touches a real DOM/Worker.
99
import {
1010
createOnceLoader, formatRunError, truncateOutput,
11-
accumulateOutput, STOPPED_NOTE, timeoutNote,
11+
accumulateOutput, STOPPED_NOTE, timeoutNote, editableText,
1212
} from '../../theme/default/js/run.js'
1313
import { PRELUDE, formatRunError as formatWorkerRunError } from '../../theme/default/js/run-worker.js'
1414

@@ -120,6 +120,15 @@ assert(STOPPED_NOTE === '(停止しました)', 'STOPPED_NOTE is the stop notice
120120
assert(timeoutNote(30) === '(30秒でタイムアウトしました)',
121121
'timeoutNote formats the configured timeout in seconds')
122122

123+
// editableText: flattening for edit drops the compiler's leading newline
124+
// (the artifact after <code>) but keeps interior/trailing newlines intact
125+
assert(editableText('\np 1\np 2\n') === 'p 1\np 2\n',
126+
'editableText strips the leading newline artifact')
127+
assert(editableText('\n\n\np 1\n') === 'p 1\n',
128+
'editableText strips all leading blank lines')
129+
assert(editableText('p 1\n\np 2\n') === 'p 1\n\np 2\n',
130+
'editableText keeps interior and trailing newlines')
131+
123132
if (failures > 0) {
124133
throw new Error(failures + ' JS test(s) failed')
125134
}

test/js/test_script.mjs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,25 @@ async function settle() {
139139
await 0
140140
}
141141

142+
// group(RUN/COPY 共用コンテナ)経由でボタンを取り出すヘルパー
143+
function buttonGroupOf(elem) { return elem.childByClass('highlight__button-group') }
144+
function copyButtonOf(elem) {
145+
const group = buttonGroupOf(elem)
146+
return group && group.childByClass('highlight__copy-button')
147+
}
148+
142149
// A ruby sample keeps getting the COPY button (regression)
143150
{
144151
const spy = clipboardSpy()
145152
const pre = new FakeElement('pre', 'highlight ruby')
146153
pre.appendChild(new FakeElement('code', '', 'puts 1\n'))
147154
runOnload([pre], spy.navigator)
148-
const btn = pre.childByClass('highlight__copy-button')
155+
const btn = copyButtonOf(pre)
149156
assert(btn !== null, 'pre.highlight.ruby gets a COPY button')
150-
assert(pre.firstChild === btn, 'COPY button is prepended as the first child')
157+
assert(pre.firstChild === buttonGroupOf(pre),
158+
'the shared button group is prepended as the first child')
159+
assert(buttonGroupOf(pre).firstChild === btn,
160+
'COPY button lives inside the button group')
151161
btn.onclick()
152162
await settle()
153163
assert(spy.written.length === 1 && spy.written[0] === 'puts 1\n',
@@ -161,7 +171,7 @@ async function settle() {
161171
const spy = clipboardSpy()
162172
const pre = new FakeElement('pre', '', 'ary = []\n')
163173
runOnload([pre], spy.navigator)
164-
const btn = pre.childByClass('highlight__copy-button')
174+
const btn = copyButtonOf(pre)
165175
assert(btn !== null, 'plain <pre> without highlight class gets a COPY button')
166176
btn.onclick()
167177
await settle()
@@ -174,7 +184,7 @@ async function settle() {
174184
const pre = new FakeElement('pre', 'highlight c')
175185
pre.appendChild(new FakeElement('code', '', 'VALUE v;\n'))
176186
runOnload([pre], clipboardSpy().navigator)
177-
assert(pre.childByClass('highlight__copy-button') !== null,
187+
assert(copyButtonOf(pre) !== null,
178188
'pre.highlight.c gets a COPY button')
179189
}
180190

@@ -183,7 +193,7 @@ async function settle() {
183193
const div = new FakeElement('div', 'highlight')
184194
const pre = new FakeElement('pre', '')
185195
runOnload([div, pre], clipboardSpy().navigator)
186-
assert(div.childByClass('highlight__copy-button') === null,
196+
assert(copyButtonOf(div) === null,
187197
'non-pre element does not get a COPY button')
188198
}
189199

@@ -194,7 +204,7 @@ async function settle() {
194204
pre.appendChild(new FakeElement('span', 'caption', '例'))
195205
pre.appendChild(new FakeElement('code', '', 'p 42\n'))
196206
runOnload([pre], spy.navigator)
197-
pre.childByClass('highlight__copy-button').onclick()
207+
copyButtonOf(pre).onclick()
198208
await settle()
199209
assert(spy.written[0] === 'p 42\n',
200210
'caption text is excluded from the copied text')
@@ -207,7 +217,7 @@ async function settle() {
207217
const spy = clipboardSpy()
208218
const pre = new FakeElement('pre', '', '\n\nputs 1\n\n\n')
209219
runOnload([pre], spy.navigator)
210-
pre.childByClass('highlight__copy-button').onclick()
220+
copyButtonOf(pre).onclick()
211221
await settle()
212222
assert(spy.written[0] === 'puts 1\n',
213223
'copied text is trimmed (leading newlines dropped, trailing squeezed)')
@@ -224,8 +234,14 @@ async function settle() {
224234
const output = new FakeElement('pre', 'highlight__run-output')
225235
let current = 'first output\n'
226236
const btn = globalThis.window.ruremaAddCopyButton(output, () => current)
227-
assert(output.firstChild === btn,
228-
'the hook prepends a COPY button to the given element')
237+
assert(output.firstChild === buttonGroupOf(output) &&
238+
buttonGroupOf(output).childByClass('highlight__copy-button') === btn,
239+
'the hook puts the COPY button into a prepended button group')
240+
// 既にグループがある要素にもう一度呼んでも、グループは1つのまま再利用される
241+
globalThis.window.ruremaAddCopyButton(output, () => current)
242+
assert(output.children.filter(
243+
c => c.className === 'highlight__button-group').length === 1,
244+
'an existing button group is reused instead of stacking a second one')
229245
btn.onclick()
230246
await settle()
231247
current = 'second output\n'
@@ -253,7 +269,7 @@ async function settle() {
253269
globalThis.navigator = {} // clipboard なし
254270
;(0, eval)(source)
255271
globalThis.window.onload()
256-
const btn = pre.childByClass('highlight__copy-button')
272+
const btn = copyButtonOf(pre)
257273
btn.onclick()
258274
await settle()
259275
assert(captured === 'fallback code\n',

theme/default/js/run.js

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ export function timeoutNote(seconds) {
7171
return `(${seconds}秒でタイムアウトしました)`
7272
}
7373

74+
// Text used when flattening the highlighted sample for editing. The
75+
// compiler emits a newline right after <code>, which textContent keeps —
76+
// left as-is it shows up as an empty first line once the block becomes
77+
// editable (script.js strips the same artifact for COPY).
78+
export function editableText(text) {
79+
return text.replace(/^\n+/, '')
80+
}
81+
7482
// Compiles the wasm module once (cached like the old single-VM version) and
7583
// hands a fresh Worker + that same compiled Module to each run. The Module
7684
// is a structured-cloneable postMessage payload, so terminate()-ing a run
@@ -102,14 +110,19 @@ function setupBlock(pre, runner) {
102110
button.type = 'button'
103111
button.className = 'highlight__run-button'
104112
button.textContent = 'RUN'
105-
// script.js has already prepended the COPY button (its window.onload
106-
// handler fires before our load listener); keep COPY rightmost.
107-
const copyButton = pre.querySelector('.highlight__copy-button')
108-
if (copyButton) {
109-
copyButton.after(button)
110-
} else {
111-
pre.prepend(button)
113+
// script.js has already created the shared button group with the COPY
114+
// button in it (its window.onload handler fires before our load
115+
// listener). Prepend RUN into the same group so COPY stays rightmost,
116+
// and so the gaps between/around the buttons belong to the group
117+
// (cursor: default) instead of the text area — hovering the cluster no
118+
// longer flickers between pointer and text cursors.
119+
let group = pre.querySelector('.highlight__button-group')
120+
if (!group) {
121+
group = document.createElement('span')
122+
group.className = 'highlight__button-group'
123+
pre.prepend(group)
112124
}
125+
group.prepend(button)
113126

114127
let output
115128
let outputTextNode
@@ -135,16 +148,48 @@ function setupBlock(pre, runner) {
135148
outputTextNode.data = text
136149
}
137150

151+
// plaintext-only が使えないブラウザ向けの paste フォールバック。
152+
// 既定のリッチペーストは改行を <br>/<div> に変えたり先頭の改行を
153+
// 落としたりする(貼り付けた内容が前の行にくっつく)ので、text/plain
154+
// をキャレット位置にそのまま挿入する
155+
const pastePlainText = (event) => {
156+
if (!event.clipboardData) return
157+
event.preventDefault()
158+
const text = event.clipboardData.getData('text/plain')
159+
const selection = window.getSelection()
160+
if (!selection || selection.rangeCount === 0) return
161+
const range = selection.getRangeAt(0)
162+
range.deleteContents()
163+
const node = document.createTextNode(text)
164+
range.insertNode(node)
165+
range.setStartAfter(node)
166+
range.collapse(true)
167+
selection.removeAllRanges()
168+
selection.addRange(range)
169+
}
170+
138171
// After the first run the sample becomes editable (like a scratchpad);
139172
// Ctrl+Enter (or Cmd+Enter) re-runs the edited code. The COPY button
140173
// keeps copying the original text.
141174
const enableEditing = () => {
142175
if (code.isContentEditable) return
143176
// 編集でハイライトの span に文字が食い込むと、貼り付けた文字が
144177
// その場の色を引き継いで中途半端に崩れるため、編集可能にする
145-
// 時点でプレーンテキスト化して色を消す
146-
code.textContent = code.textContent
147-
code.contentEditable = 'true'
178+
// 時点でプレーンテキスト化して色を消す。コンパイラが <code> の
179+
// 直後に置く改行(編集開始時に先頭の空行として見える)もここで除く
180+
code.textContent = editableText(code.textContent)
181+
// 改行入力・貼り付けが <br>/<div> にならないよう plaintext-only を
182+
// 優先する。未対応ブラウザは代入が SyntaxError になるか値が変わらない
183+
// ので、従来どおり true にして paste だけ自前で処理する
184+
try {
185+
code.contentEditable = 'plaintext-only'
186+
} catch (e) {
187+
// fall through
188+
}
189+
if (code.contentEditable !== 'plaintext-only') {
190+
code.contentEditable = 'true'
191+
code.addEventListener('paste', pastePlainText)
192+
}
148193
code.spellcheck = false
149194
pre.dataset.editing = 'true'
150195
pre.addEventListener('keydown', (event) => {

theme/default/script.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@
2222
})
2323
}
2424

25+
// elem 先頭のボタン置き場(RUN/COPY 共用の float コンテナ)を返す。
26+
// 無ければ作る。ボタンを個別に float させるとボタン間の隙間で text
27+
// カーソルとちらついてクリックしにくいため、1つのコンテナにまとめて
28+
// 隙間のカーソル形状はコンテナ(cursor: default)が引き受ける。
29+
// js/run.js も同じコンテナに RUN ボタンを入れる
30+
function buttonGroup(elem) {
31+
const first = elem.firstChild
32+
if (first && first.className &&
33+
String(first.className).split(' ').indexOf('highlight__button-group') >= 0) {
34+
return first
35+
}
36+
const group = document.createElement('span')
37+
group.setAttribute('class', 'highlight__button-group')
38+
elem.insertBefore(group, elem.firstChild)
39+
return group
40+
}
41+
2542
// elem の先頭に COPY ボタンを付ける。getText はクリック時に評価される
2643
// ので、RUN の実行結果のように内容が変わる要素にも使える
2744
function addCopyButton(elem, getText) {
@@ -35,7 +52,8 @@
3552
// コピー失敗時は従来どおり何も表示しない
3653
})
3754
}
38-
elem.insertBefore(btn, elem.firstChild)
55+
// COPY は常にグループ右端(RUN は run.js が先頭に prepend する)
56+
buttonGroup(elem).appendChild(btn)
3957
return btn
4058
}
4159

theme/default/style.css

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,21 @@ pre {
137137
}
138138

139139

140+
/* RUN/COPY ボタン共用の float コンテナ(script.js が生成)。ボタンを
141+
個別に float させるとボタン間・周囲の余白が pre の text カーソル領域の
142+
ままになり、ポインタ形状がちらついてクリックしにくいため、隙間の
143+
カーソル形状と選択不可はコンテナが引き受ける */
144+
.highlight__button-group {
145+
float: right;
146+
margin: 0 -1.1em 0.25em 0.5em;
147+
cursor: default;
148+
-webkit-user-select: none;
149+
user-select: none;
150+
}
151+
140152
/* for RUN (js/run.js, statichtml --run-ruby-wasm) */
141153
.highlight__run-button {
142-
float: right;
143-
margin: 0 0 0.25em 0.5em;
154+
margin: 0 0.5em 0 0;
144155
padding: 0.25em 0.5em;
145156
background-color: #DDD;
146157
opacity: 0.75;
@@ -171,18 +182,19 @@ pre {
171182
pre.highlight.ruby[data-editing="true"]:focus-within {
172183
outline: auto;
173184
}
185+
/* inline-block + width:100% だと float のボタン群と同じ行に入れず
186+
ボタンの下へ押し下げられて先頭に空行が見えるため、block にして
187+
通常表示と同じく先頭行がボタンの左に回り込むようにする */
174188
pre.highlight.ruby[data-editing="true"] > code {
175-
display: inline-block;
176-
width: 100%;
189+
display: block;
177190
}
178191
pre.highlight.ruby[data-editing="true"] > code:focus {
179192
outline: none;
180193
}
181194

182-
/* for COPY */
195+
/* for COPY(highlight__button-group の中に入る) */
183196
.highlight__copy-button {
184-
float: right;
185-
margin: 0 -1.1em 0.25em 0.5em;
197+
display: inline-block;
186198
padding: 0.25em 0.5em;
187199
background: #DDD;
188200
opacity: 0.75;

0 commit comments

Comments
 (0)