copy_tuner_client 2.2.1 → 2.4.0

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.
@@ -1,3 +1,5 @@
1
+ require 'copy_tuner_client/fork_hook'
2
+
1
3
  module CopyTunerClient
2
4
  # Starts the poller from a worker process, or register hooks for a spawner
3
5
  # process (such as in Unicorn or Passenger). Also registers hooks for exiting
@@ -14,6 +16,9 @@ module CopyTunerClient
14
16
 
15
17
  # Starts the poller or registers hooks
16
18
  def start
19
+ # fork の前後で poller を張り直すフックは、どのプロセスでも必ず登録する
20
+ ForkHook.install
21
+
17
22
  if spawner?
18
23
  register_spawn_hooks
19
24
  else
@@ -115,9 +120,12 @@ module CopyTunerClient
115
120
  ::Process.singleton_class.prepend(hook_module)
116
121
  end
117
122
 
123
+ # 起動方法(rails server / puma -C)とモード(single / cluster / preload の有無)ごとに
124
+ # どのプロセスで poller が起動するかは docs/poller-startup.md にまとめてある
118
125
  def register_puma_hook
119
- # If Puma is clustered mode without preload_app, this method is called on worker process.
120
- # Just start poller and return.
126
+ # 非 preload の cluster worker はここに来る。この構成ではアプリのロードが
127
+ # Puma::Runner#start_server の中で起きるため、今から prepend しても実行中の呼び出しには
128
+ # 間に合わない。つまりこの分岐が poller を起動する唯一の経路で、最適化ではなく必須
121
129
  if $PROGRAM_NAME.include?('cluster worker')
122
130
  @logger.info('Puma would be clustered mode without preload_app')
123
131
  @poller.start
@@ -125,8 +133,9 @@ module CopyTunerClient
125
133
  end
126
134
 
127
135
  @logger.info('Register Puma fork hook')
128
- # If Puma is clustered mode with preload_app, this method is called before fork.
129
- # Delay poller start until Puma::Runner#start_server which is called on worker process.
136
+ # preload ありの cluster ではこのメソッドが fork 前の master で走る。master は
137
+ # リクエストを捌かないのでここでは起動せず、worker 側で呼ばれる
138
+ # Puma::Runner#start_server まで遅らせる
130
139
  poller = @poller
131
140
  hook_module =
132
141
  Module.new do
@@ -51,10 +51,16 @@ module CopyTunerClient
51
51
 
52
52
  def wait_with_timeout(timeout)
53
53
  # wait for element or timeout
54
- timeout_time = timeout + Time.now.to_f
55
- while @queue.empty? && (remaining_time = timeout_time - Time.now.to_f).positive?
54
+ # NTP の step 補正や手動の時刻変更で巻き戻らない CLOCK_MONOTONIC で締め切りを測る。
55
+ # ウォールクロックだと時計が後ろへ飛んだぶんだけ待ち時間が伸びる
56
+ deadline = monotonic_now + timeout
57
+ while @queue.empty? && (remaining_time = deadline - monotonic_now).positive?
56
58
  @received.wait(@mutex, remaining_time)
57
59
  end
58
60
  end
61
+
62
+ def monotonic_now
63
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ end
59
65
  end
60
66
  end
@@ -1,6 +1,6 @@
1
1
  module CopyTunerClient
2
2
  # Client version
3
- VERSION = '2.2.1'.freeze
3
+ VERSION = '2.4.0'.freeze
4
4
 
5
5
  # API version being used to communicate with the server
6
6
  API_VERSION = '2.0'.freeze
@@ -0,0 +1,171 @@
1
+ require 'spec_helper'
2
+
3
+ describe CopyTunerClient::ForkHook do
4
+ let(:client) { FakeClient.new }
5
+ let(:cache) { CopyTunerClient::Cache.new(client, logger: FakeLogger.new) }
6
+ let(:poller) do
7
+ config = CopyTunerClient::Configuration.new.to_hash
8
+ CopyTunerClient::Poller.new(cache, config.update(logger: FakeLogger.new, polling_delay:))
9
+ end
10
+
11
+ def polling_delay
12
+ 0.5
13
+ end
14
+
15
+ before do
16
+ described_class.install
17
+ # フックは CopyTunerClient.configuration&.poller を見るので、実際の設定に載せる
18
+ CopyTunerClient.configuration.poller = poller
19
+ end
20
+
21
+ after do
22
+ poller.stop
23
+ end
24
+
25
+ describe '停止と再開の順序' do
26
+ # 実際に fork してしまうと「fork の瞬間にスレッドが止まっているか」を観測できないため、
27
+ # _fork の代わりに記録だけするオブジェクトに prepend して順序を見る
28
+ def build_forkable(events, &fork_body)
29
+ forkable = Object.new
30
+ forkable.define_singleton_method(:_fork) do
31
+ events << :fork
32
+ fork_body ? fork_body.call : 0
33
+ end
34
+ forkable.singleton_class.prepend(described_class)
35
+ forkable
36
+ end
37
+
38
+ def stub_poller(events, stopped:)
39
+ allow(poller).to receive(:stop) do
40
+ events << :stop
41
+ stopped
42
+ end
43
+ allow(poller).to receive(:start) { events << :start }
44
+ end
45
+
46
+ it 'fork の前に poller を停止し、fork の後に再開する' do
47
+ events = []
48
+ stub_poller(events, stopped: true)
49
+
50
+ build_forkable(events)._fork
51
+
52
+ expect(events).to eq(%i[stop fork start])
53
+ end
54
+
55
+ it 'fork 前に poller が動いていなければ再開しない' do
56
+ events = []
57
+ stub_poller(events, stopped: false)
58
+
59
+ build_forkable(events)._fork
60
+
61
+ expect(events).to eq(%i[stop fork])
62
+ end
63
+
64
+ it 'fork が失敗しても poller を再開する' do
65
+ events = []
66
+ stub_poller(events, stopped: true)
67
+
68
+ forkable = build_forkable(events) { raise Errno::EAGAIN }
69
+
70
+ expect { forkable._fork }.to raise_error(Errno::EAGAIN)
71
+ expect(events).to eq(%i[stop fork start])
72
+ end
73
+ end
74
+
75
+ describe 'poller を取得できないとき' do
76
+ it 'configuration が nil でも fork を壊さない' do
77
+ # Process._fork への prepend は外せないので、ここで例外を漏らすとアプリの
78
+ # すべての fork が失敗する
79
+ CopyTunerClient.configuration = nil
80
+
81
+ expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
82
+ end
83
+
84
+ it 'ログ出力自体が失敗しても fork は成立する' do
85
+ allow(poller).to receive(:stop).and_return(true)
86
+ allow(poller).to receive(:start).and_raise(ThreadError, 'cannot create thread')
87
+ allow(CopyTunerClient.configuration.logger).to receive(:error).and_raise('logger is broken')
88
+
89
+ expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
90
+ end
91
+
92
+ it 'fork 後の poller 起動に失敗しても fork 自体は成立する' do
93
+ allow(poller).to receive(:stop).and_return(true)
94
+ allow(poller).to receive(:start).and_raise(ThreadError, 'cannot create thread')
95
+
96
+ expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
97
+ end
98
+ end
99
+
100
+ describe '実際に fork したとき' do
101
+ it 'fork 前に poller が例外で死んでいても親子とも張り直す' do
102
+ # rails server の cluster では ForkHook が唯一の再開経路なので、ここで張り直さないと
103
+ # 親子とも poller を失う
104
+ fail_once = true
105
+ allow(cache).to receive(:sync).and_wrap_original do |original, *args|
106
+ if fail_once
107
+ fail_once = false
108
+ raise 'boom'
109
+ end
110
+ original.call(*args)
111
+ end
112
+
113
+ poller.start
114
+ sleep(polling_delay * 0.4) # スレッドが例外で終わる
115
+
116
+ reader, writer = IO.pipe
117
+ pid =
118
+ fork do
119
+ reader.close
120
+ client['child.key'] = 'value'
121
+ sleep(polling_delay * 3)
122
+ writer.write(cache['child.key'].to_s)
123
+ writer.close
124
+ exit!(0)
125
+ end
126
+
127
+ writer.close
128
+ child_result = reader.read
129
+ Process.waitpid(pid)
130
+
131
+ client['parent.key'] = 'value'
132
+ sleep(polling_delay * 3)
133
+
134
+ expect(child_result).to eq('value')
135
+ expect(cache['parent.key']).to eq('value')
136
+ end
137
+
138
+ it '子プロセスでも poller がポーリングを続ける' do
139
+ poller.start
140
+ reader, writer = IO.pipe
141
+
142
+ pid =
143
+ fork do
144
+ reader.close
145
+ client['test.key'] = 'value'
146
+ sleep(polling_delay * 3)
147
+ writer.write(cache['test.key'].to_s)
148
+ writer.close
149
+ exit!(0)
150
+ end
151
+
152
+ writer.close
153
+ result = reader.read
154
+ Process.waitpid(pid)
155
+
156
+ expect(result).to eq('value')
157
+ end
158
+
159
+ it '親プロセスでも poller がポーリングを続ける' do
160
+ poller.start
161
+ sleep(polling_delay * 0.2)
162
+
163
+ Process.waitpid(fork { exit!(0) })
164
+
165
+ client['test.key'] = 'value'
166
+ sleep(polling_delay * 3)
167
+
168
+ expect(cache['test.key']).to eq('value')
169
+ end
170
+ end
171
+ end
@@ -11,7 +11,7 @@ describe CopyTunerClient::Poller do
11
11
 
12
12
  def build_poller(config = {})
13
13
  config[:logger] ||= FakeLogger.new
14
- config[:polling_delay] = polling_delay
14
+ config[:polling_delay] ||= polling_delay
15
15
  default_config = CopyTunerClient::Configuration.new.to_hash
16
16
  poller = CopyTunerClient::Poller.new(cache, default_config.update(config))
17
17
  pollers << poller
@@ -95,4 +95,151 @@ describe CopyTunerClient::Poller do
95
95
 
96
96
  expect(logger).to have_received(:flush).at_least(:once)
97
97
  end
98
+
99
+ describe '#stop' do
100
+ it 'スレッドを停止したときは true を返す' do
101
+ poller = build_poller
102
+ poller.start
103
+
104
+ expect(poller.stop).to be true
105
+ end
106
+
107
+ it 'スレッドが動いていないときは false を返す' do
108
+ poller = build_poller
109
+
110
+ expect(poller.stop).to be false
111
+ end
112
+
113
+ it 'スレッドが例外で終わっていても例外を再送出しない' do
114
+ # stop は fork の直前にも呼ばれる。ここで join が poller の例外を再送出すると
115
+ # アプリ側の fork まで巻き添えになる
116
+ logger = FakeLogger.new
117
+ allow(cache).to receive(:sync).and_raise('boom')
118
+ poller = build_poller(logger:)
119
+ poller.start
120
+ sleep(polling_delay * 0.2)
121
+
122
+ expect { poller.stop }.not_to raise_error
123
+ expect(logger).to have_entry(:error, 'boom')
124
+ end
125
+
126
+ it 'start していないときに stop してもキューに :stop を残さない' do
127
+ poller = build_poller
128
+ poller.stop
129
+
130
+ poller.start
131
+
132
+ # 1 周目の sync だけでは「:stop がキューに残っている」状態と区別できないため、
133
+ # 2 周目以降も同期が続くことを確かめる
134
+ wait_for_next_sync
135
+ client['test.key'] = 'value'
136
+ wait_for_next_sync
137
+
138
+ expect(cache['test.key']).to eq('value')
139
+ end
140
+ end
141
+
142
+ describe '世代をまたいだコマンドの混入' do
143
+ # :stop は pop されるまでキューに残る。前の世代のスレッド宛に積まれた :stop を
144
+ # 次の世代のスレッドが拾うと、起動直後に自分を止めてしまう
145
+ it 'スレッドが例外で死んだ後に stop → start してもポーリングが続く' do
146
+ poller = build_poller
147
+ allow(cache).to receive(:sync).and_raise('boom')
148
+ poller.start
149
+ sleep(polling_delay * 0.3) # スレッドが例外で終わるのを待つ
150
+
151
+ allow(cache).to receive(:sync).and_call_original
152
+ poller.stop
153
+ poller.start
154
+
155
+ wait_for_next_sync
156
+ client['test.key'] = 'value'
157
+ wait_for_next_sync
158
+
159
+ expect(cache['test.key']).to eq('value')
160
+ end
161
+
162
+ it 'stop の直後に sync が例外で終わっても、次の start でポーリングが続く' do
163
+ poller = build_poller
164
+ # stop が :stop を積んだ後にスレッドが例外で終わる順序を作る。
165
+ # sync に入ったところで stop を待たせ、:stop を積み終えてから例外にする
166
+ syncing = Queue.new
167
+ resume = Queue.new
168
+ allow(cache).to receive(:sync) do
169
+ syncing << true
170
+ resume.pop
171
+ raise 'boom'
172
+ end
173
+
174
+ poller.start
175
+ syncing.pop # スレッドが sync に入った
176
+
177
+ stopper = Thread.new { poller.stop }
178
+ sleep(0.1) # :stop がキューに積まれるのを待つ
179
+ resume << true # ここで sync が例外になり、:stop は消費されない
180
+ stopper.join
181
+
182
+ allow(cache).to receive(:sync).and_call_original
183
+ poller.start
184
+
185
+ wait_for_next_sync
186
+ client['test.key'] = 'value'
187
+ wait_for_next_sync
188
+
189
+ expect(cache['test.key']).to eq('value')
190
+ end
191
+
192
+ # stop の戻り値は ForkHook の「fork 後に張り直すか」の判断に使われる。スレッドの生死ではなく
193
+ # 「ポーリングを継続する意図があるか」を返す必要がある
194
+ it 'スレッドが例外で死んでいても、起動中だったなら stop は true を返す' do
195
+ poller = build_poller
196
+ allow(cache).to receive(:sync).and_raise('boom')
197
+ poller.start
198
+ sleep(polling_delay * 0.3)
199
+
200
+ expect(poller.stop).to be true
201
+ end
202
+
203
+ it '回復の見込みがない理由で終了した後の stop は false を返す' do
204
+ poller = build_poller
205
+ allow(cache).to receive(:sync).and_raise(CopyTunerClient::InvalidApiKey, 'Invalid API key')
206
+ poller.start
207
+ sleep(polling_delay * 0.3)
208
+
209
+ # API キーが不正なら fork のたびに張り直しても同じ理由で死ぬだけなので再開させない
210
+ expect(poller.stop).to be false
211
+ end
212
+ end
213
+
214
+ describe '再開時の sync 間隔' do
215
+ # 経過時間で判定するため、CI の負荷や GC で sleep が伸びても落ちないよう
216
+ # 他のテストより長い間隔を使ってマージンを稼ぐ
217
+ let(:slow_delay) { 2.0 }
218
+
219
+ it '初回の start では待たずに sync する' do
220
+ poller = build_poller(polling_delay: slow_delay)
221
+
222
+ poller.start
223
+ sleep(slow_delay * 0.2)
224
+
225
+ expect(client.downloads).to eq(1)
226
+ end
227
+
228
+ it '停止直後に再開したときは前回 sync からの残り時間を待ってから sync する' do
229
+ poller = build_poller(polling_delay: slow_delay)
230
+ poller.start
231
+ sleep(slow_delay * 0.2)
232
+ poller.stop
233
+
234
+ poller.start
235
+
236
+ # 前回 sync から polling_delay 経つまでは sync しない
237
+ sleep(slow_delay * 0.4)
238
+ expect(client.downloads).to eq(1)
239
+
240
+ # 残り時間が過ぎれば sync が再開する
241
+ sleep(slow_delay * 0.8)
242
+ expect(client.downloads).to eq(2)
243
+ end
244
+ end
98
245
  end
@@ -20,6 +20,14 @@ describe CopyTunerClient::ProcessGuard do
20
20
  process_guard
21
21
  end
22
22
 
23
+ it 'fork の前後で poller を張り直すフックを登録する' do
24
+ allow(CopyTunerClient::ForkHook).to receive(:install)
25
+
26
+ build_process_guard.start
27
+
28
+ expect(CopyTunerClient::ForkHook).to have_received(:install)
29
+ end
30
+
23
31
  it 'starts polling from a worker process' do
24
32
  process_guard = build_process_guard
25
33
  process_guard.start
@@ -0,0 +1,27 @@
1
+ require 'spec_helper'
2
+ require 'timeout'
3
+
4
+ describe CopyTunerClient::QueueWithTimeout do
5
+ subject(:queue) { described_class.new }
6
+
7
+ describe '#pop_with_timeout' do
8
+ it 'キューに要素があれば取り出す' do
9
+ queue << :sync
10
+
11
+ expect(queue.pop_with_timeout(0)).to eq(:sync)
12
+ end
13
+
14
+ it '要素が無いままタイムアウトしたら ThreadError を投げる' do
15
+ expect { queue.pop_with_timeout(0.1) }.to raise_error(ThreadError)
16
+ end
17
+
18
+ it 'ウォールクロックが巻き戻ってもタイムアウトが伸びない' do
19
+ # 待っている最中に NTP の step 補正や手動の時刻変更で時計が後ろへ飛ぶ状況を模す。
20
+ # 経過時間をウォールクロックで測っていると、飛んだ幅がそのまま待ち時間に乗る
21
+ base = Time.now
22
+ allow(Time).to receive(:now).and_return(base, base - 3600)
23
+
24
+ expect { Timeout.timeout(2) { queue.pop_with_timeout(0.1) } }.to raise_error(ThreadError)
25
+ end
26
+ end
27
+ end
data/spec/spec_helper.rb CHANGED
@@ -26,4 +26,10 @@ RSpec.configure do |config|
26
26
  FakeCopyTunerApp.reset
27
27
  reset_config
28
28
  end
29
+
30
+ # apply を通す spec は本物の poller スレッドを起動するので、example ごとに止める。
31
+ # 放置するとスレッドがスイート終了まで積み上がり、実際に HTTP を叩き続ける
32
+ config.after do
33
+ CopyTunerClient.configuration&.poller&.stop
34
+ end
29
35
  end
@@ -1,5 +1,4 @@
1
1
  import { OVERLAY_STYLES } from './styles'
2
- import { computeBoundingBox } from './util'
3
2
 
4
3
  type OpenCallback = (key: string) => void
5
4
 
@@ -15,13 +14,14 @@ const findBlurbs = (): Blurb[] =>
15
14
  element,
16
15
  }))
17
16
 
18
- // オーバーレイ背景・翻訳要素のハイライト枠(specimen)・トグルボタンをまとめて Shadow DOM 内に描画する。
17
+ // オーバーレイ背景と翻訳要素のハイライト枠(specimen)を Shadow DOM 内に描画する。
19
18
  export class CopyrayOverlay extends HTMLElement {
20
19
  #onOpen: OpenCallback = () => {}
21
- #onToggle: () => void = () => {}
20
+ #onClose: () => void = () => {}
22
21
  #backdrop: HTMLDivElement
23
22
  #specimens: HTMLDivElement
24
- #toggleButton: HTMLAnchorElement
23
+ #boxes = new Map<Element, HTMLDivElement>()
24
+ #frame: number | null = null
25
25
 
26
26
  constructor() {
27
27
  super()
@@ -33,74 +33,81 @@ export class CopyrayOverlay extends HTMLElement {
33
33
 
34
34
  this.#backdrop = document.createElement('div')
35
35
  this.#backdrop.classList.add('backdrop')
36
- this.#backdrop.addEventListener('click', () => this.hide())
36
+ this.#backdrop.addEventListener('click', () => this.#onClose())
37
37
 
38
- // specimen をページ座標基準で absolute 配置するコンテナ
39
38
  this.#specimens = document.createElement('div')
40
- this.#specimens.classList.add('specimens')
41
39
 
42
- this.#toggleButton = document.createElement('a')
43
- this.#toggleButton.classList.add('toggle-button')
44
- this.#toggleButton.textContent = 'Open CopyTuner'
45
- // 旧実装ではトグルボタンが overlay と bar の両方を表示していた。show() ではなく onToggle 経由で表示する。
46
- this.#toggleButton.addEventListener('click', () => this.#onToggle())
47
-
48
- shadow.append(this.#backdrop, this.#specimens, this.#toggleButton)
49
-
50
- // 初期は非表示(背景と specimen を隠す)。トグルボタンは常時表示のため :host([hidden]) は使わず個別制御する。
51
- this.hide()
40
+ shadow.append(this.#backdrop, this.#specimens)
52
41
  }
53
42
 
54
43
  set onOpen(callback: OpenCallback) {
55
44
  this.#onOpen = callback
56
45
  }
57
46
 
58
- set onToggle(callback: () => void) {
59
- this.#onToggle = callback
60
- }
61
-
62
- get isShowing(): boolean {
63
- return !this.#backdrop.hidden
47
+ set onClose(callback: () => void) {
48
+ this.#onClose = callback
64
49
  }
65
50
 
66
51
  show() {
67
- this.reset()
68
- this.#backdrop.hidden = false
52
+ // 二重 show でもリスナーと boxes が重複しないよう、まず後始末を通す
53
+ this.hide()
69
54
 
70
55
  for (const { element, keys } of findBlurbs()) {
71
- const box = this.makeBox(element, keys)
72
- if (box) {
73
- this.#specimens.append(box)
74
- }
56
+ this.#boxes.set(element, this.makeBox(keys))
75
57
  }
58
+ // 初回は同期で配置する(rAF 経由だと開いた直後の 1 フレームだけ位置がずれる)
59
+ this.#reposition()
60
+ this.#specimens.append(...this.#boxes.values())
61
+
62
+ document.addEventListener('scroll', this.#requestReposition, { capture: true, passive: true })
63
+ window.addEventListener('resize', this.#requestReposition, { passive: true })
76
64
  }
77
65
 
78
66
  hide() {
79
- this.reset()
80
- this.#backdrop.hidden = true
81
- }
67
+ // capture フラグはリスナーの同一性判定に含まれるため、省くと解除されない
68
+ document.removeEventListener('scroll', this.#requestReposition, { capture: true })
69
+ window.removeEventListener('resize', this.#requestReposition)
70
+
71
+ // 閉じた後に走っても boxes は空で no-op になるが、無駄なコールバックを残さない
72
+ if (this.#frame !== null) {
73
+ cancelAnimationFrame(this.#frame)
74
+ this.#frame = null
75
+ }
82
76
 
83
- reset() {
77
+ // detached な box を握り続けると開くたびに肥大する
78
+ this.#boxes.clear()
84
79
  this.#specimens.replaceChildren()
85
80
  }
86
81
 
87
- private makeBox(element: Element, keys: string[]): HTMLDivElement | null {
88
- const bounds = computeBoundingBox(element)
89
- if (bounds === null) return null
82
+ // scroll は慣性スクロール中に毎フレーム相当で発火するため、同一フレーム内の再計測は 1 回にまとめる
83
+ #requestReposition = () => {
84
+ if (this.#frame !== null) return
90
85
 
86
+ this.#frame = requestAnimationFrame(() => {
87
+ this.#frame = null
88
+ this.#reposition()
89
+ })
90
+ }
91
+
92
+ #reposition() {
93
+ // 読みと書きを分けないと、要素数分の強制同期レイアウトが走る
94
+ const measured = Array.from(this.#boxes, ([element, box]) => [box, element.getBoundingClientRect()] as const)
95
+
96
+ for (const [box, rect] of measured) {
97
+ // display: none / DOM から外れた要素は rect が全て 0 になる。原点に枠だけ残さないよう隠す
98
+ box.hidden = rect.width === 0 && rect.height === 0
99
+ if (box.hidden) continue
100
+
101
+ box.style.left = `${rect.left}px`
102
+ box.style.top = `${rect.top}px`
103
+ box.style.width = `${rect.width}px`
104
+ box.style.height = `${rect.height}px`
105
+ }
106
+ }
107
+
108
+ private makeBox(keys: string[]): HTMLDivElement {
91
109
  const box = document.createElement('div')
92
110
  box.classList.add('specimen')
93
- box.style.left = `${bounds.left}px`
94
- box.style.top = `${bounds.top}px`
95
- box.style.width = `${bounds.width}px`
96
- box.style.height = `${bounds.height}px`
97
-
98
- const { position, top, left } = getComputedStyle(element)
99
- if (position === 'fixed') {
100
- box.style.position = 'fixed'
101
- box.style.top = top
102
- box.style.left = left
103
- }
104
111
 
105
112
  // box 全体のクリックは先頭キーを開く(広いクリック領域を維持)。複数キー時は各ラベルから個別に開ける
106
113
  box.addEventListener('click', () => this.#onOpen(keys[0]))
data/src/copytuner-bar.ts CHANGED
@@ -21,12 +21,6 @@ export class CopytunerBar extends HTMLElement {
21
21
  this.attachShadow({ mode: 'open' })
22
22
  }
23
23
 
24
- // custom element の constructor 内では属性・プロパティを変更できない(createElement が弾く)ため、
25
- // hidden の初期化は DOM 挿入後に呼ばれる connectedCallback で行う。
26
- connectedCallback() {
27
- this.hidden = true
28
- }
29
-
30
24
  // url/data/keysSkipped/onOpen はオブジェクトや関数を含むため属性ではなくメソッドで受け渡す。
31
25
  init({ url, data, keysSkipped, onOpen }: InitOptions) {
32
26
  this.#onOpen = onOpen
@@ -66,14 +60,9 @@ export class CopytunerBar extends HTMLElement {
66
60
  }
67
61
 
68
62
  show() {
69
- this.hidden = false
70
63
  this.#searchBox.focus()
71
64
  }
72
65
 
73
- hide() {
74
- this.hidden = true
75
- }
76
-
77
66
  private makeButton(label: string, href: string, target?: string): HTMLAnchorElement {
78
67
  const button = document.createElement('a')
79
68
  button.classList.add('button')
@@ -0,0 +1,31 @@
1
+ import { ROOT_STYLES } from './styles'
2
+
3
+ // dialog を showModal() で top layer の最上位に載せることで、アプリ側のモーダル dialog による
4
+ // inert 化を受けずに操作できる。トグルボタンだけ dialog の外に置くのは、
5
+ // 閉じている間 dialog のサブツリーが display: none になり、常時表示のボタンが消えてしまうため。
6
+ export class CopytunerRoot extends HTMLElement {
7
+ readonly dialog: HTMLDialogElement
8
+ #onToggle: () => void = () => {}
9
+
10
+ constructor() {
11
+ super()
12
+ const shadow = this.attachShadow({ mode: 'open' })
13
+
14
+ const style = document.createElement('style')
15
+ style.textContent = ROOT_STYLES
16
+ shadow.append(style)
17
+
18
+ this.dialog = document.createElement('dialog')
19
+
20
+ const toggleButton = document.createElement('a')
21
+ toggleButton.classList.add('toggle-button')
22
+ toggleButton.textContent = 'Open CopyTuner'
23
+ toggleButton.addEventListener('click', () => this.#onToggle())
24
+
25
+ shadow.append(this.dialog, toggleButton)
26
+ }
27
+
28
+ set onToggle(callback: () => void) {
29
+ this.#onToggle = callback
30
+ }
31
+ }