copy_tuner_client 2.0.0 → 2.1.1
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.
- checksums.yaml +4 -4
- data/CLAUDE.md +4 -1
- data/README.md +6 -2
- data/app/assets/javascripts/copytuner.js +162 -255
- data/biome.json +39 -0
- data/index.html +9 -11
- data/lib/copy_tuner_client/copyray_middleware.rb +0 -6
- data/lib/copy_tuner_client/engine.rb +1 -1
- data/lib/copy_tuner_client/version.rb +1 -1
- data/mise.toml +3 -0
- data/package.json +9 -13
- data/pnpm-lock.yaml +600 -0
- data/skills/copy-tuner-to-t-migrate/SKILL.md +57 -8
- data/skills/copy-tuner-to-t-migrate/scripts/migrate_tt.rb +29 -2
- data/spec/copy_tuner_client/copyray_middleware_spec.rb +1 -2
- data/src/copyray-overlay.ts +125 -0
- data/src/copytuner-bar.ts +153 -0
- data/src/main.ts +29 -25
- data/src/{copyray.css → styles.ts} +104 -139
- data/src/util.ts +9 -1
- data/tsconfig.json +5 -10
- data/vite.config.ts +0 -1
- metadata +7 -9
- data/.babelrc +0 -18
- data/.eslintrc.js +0 -12
- data/app/assets/stylesheets/copytuner.css +0 -1
- data/src/copyray.ts +0 -111
- data/src/copytuner_bar.ts +0 -129
- data/src/specimen.ts +0 -94
- data/yarn.lock +0 -2540
|
@@ -3,8 +3,9 @@ name: copy-tuner-to-t-migrate
|
|
|
3
3
|
description: >-
|
|
4
4
|
copy_tuner_client v2.0.0 への移行で、削除された独自ヘルパー tt の呼び出しを Rails 標準の t(translate)へ
|
|
5
5
|
置換するスキル。tt は PR #122 で存在理由を失い gem から削除されたため、残った tt(...) は NoMethodError に
|
|
6
|
-
なる。大半は機械的に tt( → t(
|
|
7
|
-
|
|
6
|
+
なる。大半は機械的に tt( → t( で済むが、訳文を文字列加工している箇所(→ I18n.t へ)と label 系ヘルパーの
|
|
7
|
+
第一引数に渡している箇所(→ 引数構造を分離)だけは別扱いで、開発環境のマーカートークン残留バグを避けるため
|
|
8
|
+
1 件ずつ確認する。対象は app 配下の .rb / .haml / .erb。
|
|
8
9
|
disable-model-invocation: true
|
|
9
10
|
license: MIT
|
|
10
11
|
---
|
|
@@ -36,13 +37,46 @@ middleware 自体が無いので再現しない=開発環境だけ壊れる)
|
|
|
36
37
|
`t` にすると、`tt` がもともと潰していたバグをそのまま復活させてしまう。そこは middleware のラッパーを通らない
|
|
37
38
|
**`I18n.t`(絶対キー)** へ移すのが正解で、機械的には決められないため 1 件ずつ確認する。
|
|
38
39
|
|
|
40
|
+
## もう一つの罠: label の第一引数に渡す
|
|
41
|
+
|
|
42
|
+
文字列加工していなくても危険な経路がもう一つある。`t(...)` の戻り値を **`label` 系ヘルパーの第一引数**
|
|
43
|
+
(form builder の `f.label` / `label_tag` / 素の `label`)に渡しているケース:
|
|
44
|
+
|
|
45
|
+
```haml
|
|
46
|
+
= f.label t('activerecord.attributes.field.keywords'), class: 'form-label'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`f.label` の**第一引数は「ラベルテキスト」ではなく method 名**(`for` 属性・`id`・ラベル文字列の元)として
|
|
50
|
+
扱われる。そのため development では次の連鎖でマーカーが消せない位置に残る:
|
|
51
|
+
|
|
52
|
+
1. `t(...)` が `⟦CT:activerecord.attributes.field.keywords⟧分類` を返す。
|
|
53
|
+
2. `f.label` がこれを method 名と解釈し、**ラベルテキスト**と **`for` 属性**の 2 か所に展開する。
|
|
54
|
+
3. ラベルテキスト側は Rails の `humanize` で**小文字化**され `⟦ct:…⟧` になる。
|
|
55
|
+
4. Rewriter のマーカー検出プレフィックスは **大文字 `⟦CT:` 固定**(`lib/copy_tuner_client/copyray/marker.rb`
|
|
56
|
+
の `PREFIX`)。小文字化された `⟦ct:` は一致せず、除去されないまま画面に残る。
|
|
57
|
+
|
|
58
|
+
これも v1.x では `tt`(マーカー無し版)だったため表面化せず、**`tt → t` 一括置換で初めて顕在化する回帰**。
|
|
59
|
+
|
|
60
|
+
**重要: このケースは `I18n.t` 化では直らない。** マーカーを消しても、`t`/`I18n.t` の戻り値(訳文文字列)を
|
|
61
|
+
`label` の第一引数に渡す構造自体が `humanize` / `for` 属性の挙動として不適切だからだ。正しい修正は
|
|
62
|
+
**method 名を第一引数・表示テキストを第二引数に分離**すること:
|
|
63
|
+
|
|
64
|
+
```haml
|
|
65
|
+
= f.label :keywords_cont, t('activerecord.attributes.field.keywords'), class: 'form-label'
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`for` 属性が変わるので、ラベルクリックでフィールドにフォーカスが当たることを実機で確認する。
|
|
69
|
+
スクリプトはこのパターンを suspicious のサブ種別 `label_arg` として抽出する(`label:` オプションに渡す
|
|
70
|
+
simple_form の `f.input :x, label: tt('k')` も同様に拾う)。
|
|
71
|
+
|
|
39
72
|
## 変換の 3 分類
|
|
40
73
|
|
|
41
|
-
| 分類 | 例 | 扱い |
|
|
42
|
-
|
|
43
|
-
| **safe** | `tt('views.foo')` / `= tt '.title'` / `tt(key, default: x)` | `tt(` → `t(` に決定論的一括変換 |
|
|
44
|
-
| **suspicious** | `truncate(tt('k'))` / `tt('k').length` / `tt('k')[0..n]` / `tt('k') =~ /re/` / `tt('k').gsub(...)` 等 | `I18n.t('絶対キー')` へ。**1 件ずつ確認**。スクリプトは触らない |
|
|
45
|
-
| **
|
|
74
|
+
| 分類 | サブ種別 | 例 | 扱い |
|
|
75
|
+
|---|---|---|---|
|
|
76
|
+
| **safe** | — | `tt('views.foo')` / `= tt '.title'` / `tt(key, default: x)` / `f.label :method, tt('k')`(第二引数) | `tt(` → `t(` に決定論的一括変換 |
|
|
77
|
+
| **suspicious** | `string_manipulation` | `truncate(tt('k'))` / `tt('k').length` / `tt('k')[0..n]` / `tt('k') =~ /re/` / `tt('k').gsub(...)` 等 | `I18n.t('絶対キー')` へ。**1 件ずつ確認**。スクリプトは触らない |
|
|
78
|
+
| **suspicious** | `label_arg` | `= f.label tt('k')` / `label_tag tt('k')` / `f.input :x, label: tt('k')`(label 系の**第一引数 / label: オプション**) | **`f.label :method, t('k')` へ構造を直す**(method 名を第一引数・表示テキストを第二引数に)。**`I18n.t` 化では直らない**。1 件ずつ確認 |
|
|
79
|
+
| **other** | — | `def tt` / `alias tt`(定義側)/ app 外(`lib/` 等)/ `tt` を含む別識別子の誤検出 | 最後にまとめて提示。自動変換しない |
|
|
46
80
|
|
|
47
81
|
**重要(定義側の罠):** アプリが v2.0.0 を待つ間の後方互換として `ApplicationHelper` に `def tt(key, **) = t(key, **)`
|
|
48
82
|
のような **`tt` の定義**を生やしていることがある。これは「呼び出し」ではないので `t(` へ機械置換すると Rails の `t`
|
|
@@ -80,11 +114,21 @@ suspicious / other の行は**一切触らない**(行番号で限定してい
|
|
|
80
114
|
|
|
81
115
|
### 3. suspicious な箇所を 1 件ずつ確認
|
|
82
116
|
|
|
83
|
-
ここが人間の判断が要る核心。手順 1 の suspicious 各件について、次を 1
|
|
117
|
+
ここが人間の判断が要る核心。手順 1 の suspicious 各件について、次を 1 件ずつ提示して確認を取る。
|
|
118
|
+
**suspicious はサブ種別(`[label_arg]` / `[string_manipulation]`)で修正方法が異なる**ので、種別で分岐する:
|
|
84
119
|
|
|
120
|
+
**`[string_manipulation]`(戻り値を文字列加工している):**
|
|
85
121
|
- 該当コード(`ファイル:行`)と、なぜ怪しいか(戻り値を文字列加工している=マーカー混入の危険)
|
|
86
122
|
- 提案する置換: `t(...)` ではなく **`I18n.t('絶対キー')`**(`I18n` モジュール直呼びはラッパーを通らずマーカーが付かない)
|
|
87
123
|
|
|
124
|
+
**`[label_arg]`(label 系の第一引数 / label: オプションに渡している):**
|
|
125
|
+
- なぜ怪しいか(`humanize` で小文字化された `⟦ct:…⟧` が大文字 `⟦CT:` 固定の Rewriter をすり抜け残る。「もう一つの罠」参照)
|
|
126
|
+
- 提案する置換: **`I18n.t` 化ではなく引数構造を直す。** `= f.label t('views.foo.bar'), class: …` →
|
|
127
|
+
`= f.label :method_name, t('views.foo.bar'), class: …`。第一引数の method 名(`for` 属性に使うシンボル)は
|
|
128
|
+
ユーザに確認するか、対応する入力フィールド(`f.text_field :keywords_cont` 等)から推定する。
|
|
129
|
+
`label:` オプション(`f.input :x, label: tt('k')`)の場合は引数構造はそのままで `t('k')` でよいことが多いが、
|
|
130
|
+
そのフィールドのラベルが `humanize` を経るかは入力次第なので 1 件ずつ確認する。
|
|
131
|
+
|
|
88
132
|
注意点:
|
|
89
133
|
- **たとえユーザが「全部まとめて I18n.t にして」と言っても、suspicious は必ず 1 件ずつ提示する。** 相対キーを含む件は絶対キーをユーザと確認しないと置換できないためで、省略すると誤ったキーを書き込む危険がある。
|
|
90
134
|
- **相対キー(先頭ドット `tt('.foo')`)は `I18n.t` では解決できない。** 絶対キー(`I18n.t('views.foo.bar')`)へ
|
|
@@ -94,6 +138,8 @@ suspicious / other の行は**一切触らない**(行番号で限定してい
|
|
|
94
138
|
調べて基点キーを特定するか、ユーザに直接絶対キーを確認する。絶対キーが不明なときは
|
|
95
139
|
`grep -r "キー末尾部分" config/locales/` でロケールファイルを検索して候補を絞り込む。
|
|
96
140
|
- 本当に文字列加工していない(誤検出)なら、その場合は `t(...)` でよい。ユーザの判断に従う。
|
|
141
|
+
- **行をまたぐ変数経由(`txt = t('k')` → 別行で `f.label txt` や `truncate(txt)`)はスクリプトの行単位
|
|
142
|
+
スキャンでは拾えない**(構造的限界)。最終防波堤は手順 5 の「実画面に小文字マーカー `⟦ct:…⟧` が出ないか目視」。
|
|
97
143
|
|
|
98
144
|
確認が取れた箇所だけ Edit で個別に置換する(スクリプトでは変換しない)。
|
|
99
145
|
|
|
@@ -118,6 +164,9 @@ git grep -nwI tt -- app # -I でバイナリ(画像等)の誤マッチを
|
|
|
118
164
|
(safe は 0 件、`I18n.t` 化した suspicious も `tt` としては消える)。
|
|
119
165
|
- 対象アプリで `bundle exec rspec`(または該当アプリのテスト)を回し、`NoMethodError` が出ないこと。
|
|
120
166
|
- development で実画面を開き、文字列加工していた箇所にマーカートークン(`⟦CT:...⟧`)が混入していないこと。
|
|
167
|
+
- **label を含む画面で、小文字マーカー `⟦ct:...⟧`(小文字 ct)が残っていないこと。** Rewriter は大文字
|
|
168
|
+
`⟦CT:` しか除去しないため、`humanize` で小文字化された取りこぼしは**この目視が最終防波堤**になる
|
|
169
|
+
(label 第一引数の罠・行をまたぐ変数経由はここで初めて顕在化することがある)。
|
|
121
170
|
|
|
122
171
|
## スクリプトの責務(決定論的な範囲のみ)
|
|
123
172
|
|
|
@@ -81,6 +81,18 @@ STRING_METHOD_RE = /\btt\b\s*(?:\([^\n]*?\)|['":@][^\n,]*)\s*(?:\.\s*(?:#{STRING
|
|
|
81
81
|
# 先頭ドットの相対キー tt('.foo') は I18n.t では解決できず絶対キー化が要る → 強調表示用。
|
|
82
82
|
RELATIVE_KEY_RE = /\btt\b\s*(?:\(\s*)?['":]\s*\./
|
|
83
83
|
|
|
84
|
+
# label 系ヘルパーの第一引数 / label: オプションに tt(t) を渡すパターン。
|
|
85
|
+
# t 化すると戻り値(⟦CT:..⟧) が method 名扱いされ、humanize で小文字化された ⟦ct:..⟧ が
|
|
86
|
+
# Rewriter(大文字 ⟦CT: 固定)の除去網をすり抜け画面に残る。I18n.t 化では直らない。
|
|
87
|
+
# 正しい修正は method 名を第一引数・表示テキストを第二引数へ分離すること。
|
|
88
|
+
LABEL_FIRST_ARG_RE = /
|
|
89
|
+
(?: (?<![\w.])\w+\.label\b | \blabel_tag\b | (?<![\w.])label(?![\w]) )
|
|
90
|
+
\s*\(?\s*
|
|
91
|
+
tt\b (?=\s*[("':@])
|
|
92
|
+
/x
|
|
93
|
+
# simple_form 等の label: オプションに渡すケース(f.input :x, label: tt('k'))
|
|
94
|
+
LABEL_OPTION_RE = /\blabel:\s*tt\b/
|
|
95
|
+
|
|
84
96
|
def relative(path)
|
|
85
97
|
path.sub("#{ROOT}/", '')
|
|
86
98
|
end
|
|
@@ -117,8 +129,19 @@ def classify_line(line, entry, in_app:)
|
|
|
117
129
|
return [:other, entry]
|
|
118
130
|
end
|
|
119
131
|
|
|
132
|
+
# label の第一引数 / label: オプションに渡すケースは文字列加工が無いので、safe 確定より前に拾う。
|
|
133
|
+
# 修正方法が文字列加工系(I18n.t 化)と異なるためサブ種別 :label_arg で区別する。
|
|
134
|
+
if line.match?(LABEL_FIRST_ARG_RE) || line.match?(LABEL_OPTION_RE)
|
|
135
|
+
entry[:kind] = :label_arg
|
|
136
|
+
entry[:hint] = 'label の引数に渡している。t 化すると humanize で小文字化された ⟦ct:..⟧ が ' \
|
|
137
|
+
'Rewriter(大文字 ⟦CT: のみ除去)をすり抜け画面に残る。I18n.t 化では直らない。' \
|
|
138
|
+
'method 名を第一引数・表示テキストを第二引数に分離: f.label :method, t(...)'
|
|
139
|
+
return [:suspicious, entry]
|
|
140
|
+
end
|
|
141
|
+
|
|
120
142
|
return [:safe, entry] unless line.match?(STRING_HELPER_RE) || line.match?(STRING_METHOD_RE)
|
|
121
143
|
|
|
144
|
+
entry[:kind] = :string_manipulation
|
|
122
145
|
entry[:relative_key] = line.match?(RELATIVE_KEY_RE)
|
|
123
146
|
entry[:hint] = if entry[:relative_key]
|
|
124
147
|
'I18n.t へ。相対キー(.foo)は絶対キーへ書き換えが必要'
|
|
@@ -147,10 +170,14 @@ end
|
|
|
147
170
|
def print_section(title, entries)
|
|
148
171
|
puts "\n== #{title} (#{entries.size}) =="
|
|
149
172
|
entries.each do |e|
|
|
173
|
+
# hint があれば必ず kind も付く(classify_line で対で設定)ので素直に出す。
|
|
174
|
+
# suspicious 内のサブ種別(label_arg / string_manipulation)を可視化する。
|
|
150
175
|
suffix = if e[:hint]
|
|
151
|
-
" # #{e[:hint]}"
|
|
176
|
+
" # [#{e[:kind]}] #{e[:hint]}"
|
|
177
|
+
elsif e[:reason]
|
|
178
|
+
" # #{e[:reason]}"
|
|
152
179
|
else
|
|
153
|
-
|
|
180
|
+
''
|
|
154
181
|
end
|
|
155
182
|
puts "#{e[:file]}:#{e[:lineno]}: #{e[:code].strip}#{suffix}"
|
|
156
183
|
end
|
|
@@ -19,9 +19,8 @@ describe CopyTunerClient::CopyrayMiddleware do
|
|
|
19
19
|
configuration.project_id = 1
|
|
20
20
|
configuration.client = FakeClient.new
|
|
21
21
|
end
|
|
22
|
-
# NOTE:
|
|
22
|
+
# NOTE: JS 挿入は Rails の ActionController::Base.helpers に依存するため、
|
|
23
23
|
# Rewriter の効果だけを検証できるよう no-op にスタブする。
|
|
24
|
-
allow(middleware).to receive(:append_css) { |html, _| html }
|
|
25
24
|
allow(middleware).to receive(:append_js) { |html, *| html }
|
|
26
25
|
end
|
|
27
26
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { OVERLAY_STYLES } from './styles'
|
|
2
|
+
import { computeBoundingBox } from './util'
|
|
3
|
+
|
|
4
|
+
type OpenCallback = (key: string) => void
|
|
5
|
+
|
|
6
|
+
type Blurb = {
|
|
7
|
+
keys: string[]
|
|
8
|
+
element: Element
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const findBlurbs = (): Blurb[] =>
|
|
12
|
+
Array.from(document.querySelectorAll('[data-copyray-key]')).map((element) => ({
|
|
13
|
+
// 1 要素に複数キーがカンマ区切りで入りうる(同一テキストノードに複数訳文が連結された場合)
|
|
14
|
+
keys: (element.getAttribute('data-copyray-key') ?? '').split(',').filter(Boolean),
|
|
15
|
+
element,
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
// オーバーレイ背景・翻訳要素のハイライト枠(specimen)・トグルボタンをまとめて Shadow DOM 内に描画する。
|
|
19
|
+
export class CopyrayOverlay extends HTMLElement {
|
|
20
|
+
#onOpen: OpenCallback = () => {}
|
|
21
|
+
#onToggle: () => void = () => {}
|
|
22
|
+
#backdrop: HTMLDivElement
|
|
23
|
+
#specimens: HTMLDivElement
|
|
24
|
+
#toggleButton: HTMLAnchorElement
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
super()
|
|
28
|
+
const shadow = this.attachShadow({ mode: 'open' })
|
|
29
|
+
|
|
30
|
+
const style = document.createElement('style')
|
|
31
|
+
style.textContent = OVERLAY_STYLES
|
|
32
|
+
shadow.append(style)
|
|
33
|
+
|
|
34
|
+
this.#backdrop = document.createElement('div')
|
|
35
|
+
this.#backdrop.classList.add('backdrop')
|
|
36
|
+
this.#backdrop.addEventListener('click', () => this.hide())
|
|
37
|
+
|
|
38
|
+
// specimen をページ座標基準で absolute 配置するコンテナ
|
|
39
|
+
this.#specimens = document.createElement('div')
|
|
40
|
+
this.#specimens.classList.add('specimens')
|
|
41
|
+
|
|
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()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
set onOpen(callback: OpenCallback) {
|
|
55
|
+
this.#onOpen = callback
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
set onToggle(callback: () => void) {
|
|
59
|
+
this.#onToggle = callback
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get isShowing(): boolean {
|
|
63
|
+
return !this.#backdrop.hidden
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
show() {
|
|
67
|
+
this.reset()
|
|
68
|
+
this.#backdrop.hidden = false
|
|
69
|
+
|
|
70
|
+
for (const { element, keys } of findBlurbs()) {
|
|
71
|
+
const box = this.makeBox(element, keys)
|
|
72
|
+
if (box) {
|
|
73
|
+
this.#specimens.append(box)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
hide() {
|
|
79
|
+
this.reset()
|
|
80
|
+
this.#backdrop.hidden = true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
reset() {
|
|
84
|
+
this.#specimens.replaceChildren()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private makeBox(element: Element, keys: string[]): HTMLDivElement | null {
|
|
88
|
+
const bounds = computeBoundingBox(element)
|
|
89
|
+
if (bounds === null) return null
|
|
90
|
+
|
|
91
|
+
const box = document.createElement('div')
|
|
92
|
+
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
|
+
|
|
105
|
+
// box 全体のクリックは先頭キーを開く(広いクリック領域を維持)。複数キー時は各ラベルから個別に開ける
|
|
106
|
+
box.addEventListener('click', () => this.#onOpen(keys[0]))
|
|
107
|
+
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
box.append(this.makeLabel(key))
|
|
110
|
+
}
|
|
111
|
+
return box
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private makeLabel(key: string): HTMLDivElement {
|
|
115
|
+
const label = document.createElement('div')
|
|
116
|
+
label.classList.add('specimen-handle')
|
|
117
|
+
label.textContent = key
|
|
118
|
+
// ラベルのクリックはそのキーを開く。box への伝播を止めて先頭キーとの二重発火を防ぐ
|
|
119
|
+
label.addEventListener('click', (event) => {
|
|
120
|
+
event.stopPropagation()
|
|
121
|
+
this.#onOpen(key)
|
|
122
|
+
})
|
|
123
|
+
return label
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { BAR_STYLES } from './styles'
|
|
2
|
+
import { debounce } from './util'
|
|
3
|
+
|
|
4
|
+
type OpenCallback = (key: string) => void
|
|
5
|
+
|
|
6
|
+
type InitOptions = {
|
|
7
|
+
url: string
|
|
8
|
+
data: Record<string, string>
|
|
9
|
+
keysSkipped: boolean
|
|
10
|
+
onOpen: OpenCallback
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// 画面下部のツールバー。CopyTuner / Sync ボタン、ページ内翻訳の検索・ログメニューを Shadow DOM 内に描画する。
|
|
14
|
+
export class CopytunerBar extends HTMLElement {
|
|
15
|
+
#onOpen: OpenCallback = () => {}
|
|
16
|
+
#searchBox!: HTMLInputElement
|
|
17
|
+
#logMenu!: HTMLDivElement
|
|
18
|
+
|
|
19
|
+
constructor() {
|
|
20
|
+
super()
|
|
21
|
+
this.attachShadow({ mode: 'open' })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// custom element の constructor 内では属性・プロパティを変更できない(createElement が弾く)ため、
|
|
25
|
+
// hidden の初期化は DOM 挿入後に呼ばれる connectedCallback で行う。
|
|
26
|
+
connectedCallback() {
|
|
27
|
+
this.hidden = true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// url/data/keysSkipped/onOpen はオブジェクトや関数を含むため属性ではなくメソッドで受け渡す。
|
|
31
|
+
init({ url, data, keysSkipped, onOpen }: InitOptions) {
|
|
32
|
+
this.#onOpen = onOpen
|
|
33
|
+
const shadow = this.shadowRoot as ShadowRoot
|
|
34
|
+
|
|
35
|
+
const style = document.createElement('style')
|
|
36
|
+
style.textContent = BAR_STYLES
|
|
37
|
+
shadow.append(style)
|
|
38
|
+
|
|
39
|
+
// 元々 Rails から出力されていたマークアップに合わせたボタン群。
|
|
40
|
+
// url は設定値だが innerHTML に直接埋めず setAttribute で渡す(XSS 面で安全側に倒す)。
|
|
41
|
+
const copyTunerButton = this.makeButton('CopyTuner', url, '_blank')
|
|
42
|
+
const syncButton = this.makeButton('Sync', '/copytuner', '_blank')
|
|
43
|
+
const openLogButton = this.makeButton('Translations in this page', 'javascript:void(0)')
|
|
44
|
+
|
|
45
|
+
this.#searchBox = document.createElement('input')
|
|
46
|
+
this.#searchBox.type = 'text'
|
|
47
|
+
this.#searchBox.classList.add('search')
|
|
48
|
+
this.#searchBox.placeholder = 'search'
|
|
49
|
+
|
|
50
|
+
shadow.append(copyTunerButton, syncButton, openLogButton, this.#searchBox)
|
|
51
|
+
|
|
52
|
+
this.#logMenu = this.makeLogMenu(data)
|
|
53
|
+
shadow.append(this.#logMenu)
|
|
54
|
+
|
|
55
|
+
// 巨大DOM/Nokogiri例外でキー付与がスキップされた場合は、オーバーレイが使えないので
|
|
56
|
+
// ツールバー(Translations in this page)から編集する旨を案内する。
|
|
57
|
+
if (keysSkipped) {
|
|
58
|
+
this.appendSkippedNotice()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
openLogButton.addEventListener('click', (event) => {
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
this.toggleLogMenu()
|
|
64
|
+
})
|
|
65
|
+
this.#searchBox.addEventListener('input', debounce(this.onSearch.bind(this), 250))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
show() {
|
|
69
|
+
this.hidden = false
|
|
70
|
+
this.#searchBox.focus()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
hide() {
|
|
74
|
+
this.hidden = true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private makeButton(label: string, href: string, target?: string): HTMLAnchorElement {
|
|
78
|
+
const button = document.createElement('a')
|
|
79
|
+
button.classList.add('button')
|
|
80
|
+
button.textContent = label
|
|
81
|
+
button.href = href
|
|
82
|
+
if (target) {
|
|
83
|
+
button.target = target
|
|
84
|
+
}
|
|
85
|
+
return button
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private appendSkippedNotice() {
|
|
89
|
+
const notice = document.createElement('span')
|
|
90
|
+
notice.classList.add('notice')
|
|
91
|
+
notice.textContent = '⚠ This page is too large for the overlay. Use "Translations in this page" to edit.'
|
|
92
|
+
;(this.shadowRoot as ShadowRoot).append(notice)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private showLogMenu() {
|
|
96
|
+
this.#logMenu.hidden = false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private toggleLogMenu() {
|
|
100
|
+
this.#logMenu.hidden = !this.#logMenu.hidden
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private makeLogMenu(data: Record<string, string>): HTMLDivElement {
|
|
104
|
+
const div = document.createElement('div')
|
|
105
|
+
div.classList.add('log-menu')
|
|
106
|
+
div.hidden = true
|
|
107
|
+
|
|
108
|
+
const table = document.createElement('table')
|
|
109
|
+
const tbody = document.createElement('tbody')
|
|
110
|
+
|
|
111
|
+
for (const key of Object.keys(data).sort()) {
|
|
112
|
+
const value = data[key]
|
|
113
|
+
if (value === '') {
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const td1 = document.createElement('td')
|
|
118
|
+
td1.textContent = key
|
|
119
|
+
const td2 = document.createElement('td')
|
|
120
|
+
td2.textContent = value
|
|
121
|
+
const tr = document.createElement('tr')
|
|
122
|
+
tr.dataset.key = key
|
|
123
|
+
|
|
124
|
+
tr.addEventListener('click', ({ currentTarget }) => {
|
|
125
|
+
const row = currentTarget as HTMLTableRowElement
|
|
126
|
+
if (row.dataset.key) {
|
|
127
|
+
this.#onOpen(row.dataset.key)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
tr.append(td1, td2)
|
|
132
|
+
tbody.append(tr)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
table.append(tbody)
|
|
136
|
+
div.append(table)
|
|
137
|
+
|
|
138
|
+
return div
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private onSearch() {
|
|
142
|
+
// debounce 経由で遅延実行されると Event.target は null 化されるため、検索ボックスを直接参照する
|
|
143
|
+
const keyword = this.#searchBox.value.trim()
|
|
144
|
+
this.showLogMenu()
|
|
145
|
+
|
|
146
|
+
const rows = [...this.#logMenu.querySelectorAll('tr')]
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
const isShow =
|
|
149
|
+
keyword === '' || [...row.querySelectorAll('td')].some((td) => (td.textContent ?? '').includes(keyword))
|
|
150
|
+
row.hidden = !isShow
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
data/src/main.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* eslint-disable no-console */
|
|
2
|
-
import
|
|
2
|
+
import { CopyrayOverlay } from './copyray-overlay'
|
|
3
|
+
import { CopytunerBar } from './copytuner-bar'
|
|
3
4
|
import { isMac } from './util'
|
|
4
5
|
|
|
5
6
|
declare global {
|
|
@@ -7,8 +8,7 @@ declare global {
|
|
|
7
8
|
CopyTuner: {
|
|
8
9
|
url: string
|
|
9
10
|
toggle?: () => void
|
|
10
|
-
|
|
11
|
-
data: object
|
|
11
|
+
data: Record<string, string>
|
|
12
12
|
// 巨大DOM/Nokogiri例外で data-copyray-key 付与をスキップしたか。
|
|
13
13
|
// true のときオーバーレイは使えないのでツールバーから編集する旨を案内する。
|
|
14
14
|
keysSkipped?: boolean
|
|
@@ -16,38 +16,42 @@ declare global {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// NOTE: 元々railsから出力されいてたマークアップに合わせてひとまず、、
|
|
22
|
-
const appendCopyTunerBar = (url: string) => {
|
|
23
|
-
const bar = document.createElement('div')
|
|
24
|
-
bar.id = 'copy-tuner-bar'
|
|
25
|
-
bar.classList.add('copy-tuner-hidden')
|
|
26
|
-
bar.innerHTML = `
|
|
27
|
-
<a class="copy-tuner-bar-button" target="_blank" href="${url}">CopyTuner</a>
|
|
28
|
-
<a href="/copytuner" target="_blank" class="copy-tuner-bar-button">Sync</a>
|
|
29
|
-
<a href="javascript:void(0)" class="copy-tuner-bar-open-log copy-tuner-bar-button js-copy-tuner-bar-open-log">Translations in this page</a>
|
|
30
|
-
<input type="text" class="copy-tuner-bar__search js-copy-tuner-bar-search" placeholder="search">
|
|
31
|
-
`
|
|
32
|
-
document.body.append(bar)
|
|
33
|
-
}
|
|
19
|
+
customElements.define('copytuner-bar', CopytunerBar)
|
|
20
|
+
customElements.define('copyray-overlay', CopyrayOverlay)
|
|
34
21
|
|
|
35
22
|
const start = () => {
|
|
36
23
|
const { url, data, keysSkipped } = window.CopyTuner
|
|
24
|
+
const onOpen = (key: string) => window.open(`${url}/blurbs/${key}/edit`)
|
|
25
|
+
|
|
26
|
+
const bar = document.createElement('copytuner-bar') as CopytunerBar
|
|
27
|
+
document.body.append(bar)
|
|
28
|
+
bar.init({ url, data, keysSkipped: Boolean(keysSkipped), onOpen })
|
|
29
|
+
|
|
30
|
+
const overlay = document.createElement('copyray-overlay') as CopyrayOverlay
|
|
31
|
+
overlay.onOpen = onOpen
|
|
32
|
+
document.body.append(overlay)
|
|
33
|
+
|
|
34
|
+
const show = () => {
|
|
35
|
+
overlay.show()
|
|
36
|
+
bar.show()
|
|
37
|
+
}
|
|
38
|
+
const hide = () => {
|
|
39
|
+
overlay.hide()
|
|
40
|
+
bar.hide()
|
|
41
|
+
}
|
|
42
|
+
const toggle = () => (overlay.isShowing ? hide() : show())
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
window.CopyTuner.toggle = () => copyray.toggle()
|
|
44
|
+
overlay.onToggle = toggle
|
|
45
|
+
window.CopyTuner.toggle = toggle
|
|
41
46
|
|
|
42
47
|
document.addEventListener('keydown', (event) => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
copyray.hide()
|
|
48
|
+
if (overlay.isShowing && ['Escape', 'Esc'].includes(event.key)) {
|
|
49
|
+
hide()
|
|
46
50
|
return
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
if (((isMac && event.metaKey) || (!isMac && event.ctrlKey)) && event.shiftKey && event.key.toLowerCase() === 'k') {
|
|
50
|
-
|
|
54
|
+
toggle()
|
|
51
55
|
}
|
|
52
56
|
})
|
|
53
57
|
|