copy_tuner_client 2.3.0 → 2.5.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.
- checksums.yaml +4 -4
- data/app/assets/javascripts/copytuner.js +69 -78
- data/index.html +54 -0
- data/lib/copy_tuner_client/copyray.rb +4 -0
- data/lib/copy_tuner_client/copyray_middleware.rb +2 -0
- data/lib/copy_tuner_client/engine.rb +7 -0
- data/lib/copy_tuner_client/helper_extension.rb +38 -4
- data/lib/copy_tuner_client/version.rb +1 -1
- data/spec/copy_tuner_client/copyray_middleware_spec.rb +14 -0
- data/spec/copy_tuner_client/helper_extension_spec.rb +101 -5
- data/src/copyray-overlay.ts +54 -47
- data/src/copytuner-bar.ts +0 -11
- data/src/copytuner-root.ts +31 -0
- data/src/main.ts +22 -12
- data/src/styles.ts +62 -41
- data/src/util.ts +1 -36
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 938a08aeb03719e98a6e118eb714b820798f91ff3d5e69cce65eeea3d8b243ca
|
|
4
|
+
data.tar.gz: 9da7bbe8fa7ed6b92b90fa090f9ebf5ec67cb8945bace845494aa7638640b3ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 86d05a06a42a06da4586d60bc80649cdb9f4cd98c26425d6528f7b121c2ca880b366bc12aeec7b20952bbc7129b2837dcd9261851bb960ddebd7de773e0b64b6
|
|
7
|
+
data.tar.gz: 831783835b4ede6b590dd298e89f596c8c9ca417260e4ae490f8b6bbca576e07c7b5f89a490eb8b2b651e305a66b0c9dac7c526f0592d2fdef7f7337d70f6607
|
|
@@ -1,69 +1,50 @@
|
|
|
1
1
|
//#region src/styles.ts
|
|
2
|
-
var e =
|
|
3
|
-
let t = e.getBoundingClientRect();
|
|
4
|
-
return {
|
|
5
|
-
top: t.top + (window.pageYOffset - document.documentElement.clientTop),
|
|
6
|
-
left: t.left + (window.pageXOffset - document.documentElement.clientLeft)
|
|
7
|
-
};
|
|
8
|
-
}, r = (e) => {
|
|
9
|
-
if (!t(e)) return null;
|
|
10
|
-
let r = n(e);
|
|
11
|
-
return r.right = r.left + e.offsetWidth, r.bottom = r.top + e.offsetHeight, {
|
|
12
|
-
left: r.left,
|
|
13
|
-
top: r.top,
|
|
14
|
-
width: r.right - r.left,
|
|
15
|
-
height: r.bottom - r.top
|
|
16
|
-
};
|
|
17
|
-
}, i = (e, t) => {
|
|
18
|
-
let n;
|
|
19
|
-
return (...r) => {
|
|
20
|
-
clearTimeout(n), n = setTimeout(() => e(...r), t);
|
|
21
|
-
};
|
|
22
|
-
}, a = () => Array.from(document.querySelectorAll("[data-copyray-key]")).map((e) => ({
|
|
2
|
+
var e = () => Array.from(document.querySelectorAll("[data-copyray-key]")).map((e) => ({
|
|
23
3
|
keys: (e.getAttribute("data-copyray-key") ?? "").split(",").filter(Boolean),
|
|
24
4
|
element: e
|
|
25
|
-
})),
|
|
5
|
+
})), t = class extends HTMLElement {
|
|
26
6
|
#e = () => {};
|
|
27
7
|
#t = () => {};
|
|
28
8
|
#n;
|
|
29
9
|
#r;
|
|
30
|
-
#i;
|
|
10
|
+
#i = /* @__PURE__ */ new Map();
|
|
11
|
+
#a = null;
|
|
31
12
|
constructor() {
|
|
32
13
|
super();
|
|
33
14
|
let e = this.attachShadow({ mode: "open" }), t = document.createElement("style");
|
|
34
|
-
t.textContent = "\n:host {\n position: absolute;\n
|
|
15
|
+
t.textContent = "\n:host {\n position: absolute;\n width: 0;\n height: 0;\n}\n\n.backdrop {\n position: fixed;\n inset: 0;\n background-image: radial-gradient(\n ellipse farthest-corner at center,\n rgba(0, 0, 0, 0.4) 10%,\n rgba(0, 0, 0, 0.8) 100%\n );\n z-index: 9000;\n}\n\n.specimen {\n position: fixed;\n background: rgba(255, 50, 50, 0.1);\n outline: 1px solid rgba(255, 50, 50, 0.8);\n outline-offset: -1px;\n color: #666;\n font-family: 'Helvetica Neue', sans-serif;\n font-size: 13px;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);\n z-index: 2000000000;\n}\n\n.specimen:hover {\n cursor: pointer;\n background: rgba(255, 50, 50, 0.4);\n}\n\n.specimen[hidden] {\n display: none;\n}\n\n.specimen-handle {\n float: left;\n margin: 0 2px 2px 0;\n background: rgba(255, 50, 50, 0.8);\n padding: 0 3px;\n color: #fff;\n font-size: 10px;\n cursor: pointer;\n}\n", e.append(t), this.#n = document.createElement("div"), this.#n.classList.add("backdrop"), this.#n.addEventListener("click", () => this.#t()), this.#r = document.createElement("div"), e.append(this.#n, this.#r);
|
|
35
16
|
}
|
|
36
17
|
set onOpen(e) {
|
|
37
18
|
this.#e = e;
|
|
38
19
|
}
|
|
39
|
-
set
|
|
20
|
+
set onClose(e) {
|
|
40
21
|
this.#t = e;
|
|
41
22
|
}
|
|
42
|
-
get isShowing() {
|
|
43
|
-
return !this.#n.hidden;
|
|
44
|
-
}
|
|
45
23
|
show() {
|
|
46
|
-
this.
|
|
47
|
-
for (let { element:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
24
|
+
this.hide();
|
|
25
|
+
for (let { element: t, keys: n } of e()) this.#i.set(t, this.makeBox(n));
|
|
26
|
+
this.#s(), this.#r.append(...this.#i.values()), document.addEventListener("scroll", this.#o, {
|
|
27
|
+
capture: !0,
|
|
28
|
+
passive: !0
|
|
29
|
+
}), window.addEventListener("resize", this.#o, { passive: !0 });
|
|
51
30
|
}
|
|
52
31
|
hide() {
|
|
53
|
-
this.
|
|
32
|
+
document.removeEventListener("scroll", this.#o, { capture: !0 }), window.removeEventListener("resize", this.#o), this.#a !== null && (cancelAnimationFrame(this.#a), this.#a = null), this.#i.clear(), this.#r.replaceChildren();
|
|
54
33
|
}
|
|
55
|
-
|
|
56
|
-
this.#
|
|
34
|
+
#o = () => {
|
|
35
|
+
this.#a === null && (this.#a = requestAnimationFrame(() => {
|
|
36
|
+
this.#a = null, this.#s();
|
|
37
|
+
}));
|
|
38
|
+
};
|
|
39
|
+
#s() {
|
|
40
|
+
let e = Array.from(this.#i, ([e, t]) => [t, e.getBoundingClientRect()]);
|
|
41
|
+
for (let [t, n] of e) t.hidden = n.width === 0 && n.height === 0, !t.hidden && (t.style.left = `${n.left}px`, t.style.top = `${n.top}px`, t.style.width = `${n.width}px`, t.style.height = `${n.height}px`);
|
|
57
42
|
}
|
|
58
|
-
makeBox(e
|
|
59
|
-
let
|
|
60
|
-
|
|
61
|
-
let
|
|
62
|
-
|
|
63
|
-
let { position: a, top: o, left: s } = getComputedStyle(e);
|
|
64
|
-
a === "fixed" && (i.style.position = "fixed", i.style.top = o, i.style.left = s), i.addEventListener("click", () => this.#e(t[0]));
|
|
65
|
-
for (let e of t) i.append(this.makeLabel(e));
|
|
66
|
-
return i;
|
|
43
|
+
makeBox(e) {
|
|
44
|
+
let t = document.createElement("div");
|
|
45
|
+
t.classList.add("specimen"), t.addEventListener("click", () => this.#e(e[0]));
|
|
46
|
+
for (let n of e) t.append(this.makeLabel(n));
|
|
47
|
+
return t;
|
|
67
48
|
}
|
|
68
49
|
makeLabel(e) {
|
|
69
50
|
let t = document.createElement("div");
|
|
@@ -71,30 +52,29 @@ var e = navigator.platform.toUpperCase().includes("MAC"), t = (e) => !!(e.offset
|
|
|
71
52
|
t.stopPropagation(), this.#e(e);
|
|
72
53
|
}), t;
|
|
73
54
|
}
|
|
74
|
-
},
|
|
55
|
+
}, n = navigator.platform.toUpperCase().includes("MAC"), r = (e, t) => {
|
|
56
|
+
let n;
|
|
57
|
+
return (...r) => {
|
|
58
|
+
clearTimeout(n), n = setTimeout(() => e(...r), t);
|
|
59
|
+
};
|
|
60
|
+
}, i = class extends HTMLElement {
|
|
75
61
|
#e = () => {};
|
|
76
62
|
#t;
|
|
77
63
|
#n;
|
|
78
64
|
constructor() {
|
|
79
65
|
super(), this.attachShadow({ mode: "open" });
|
|
80
66
|
}
|
|
81
|
-
|
|
82
|
-
this
|
|
83
|
-
}
|
|
84
|
-
init({ url: e, data: t, keysSkipped: n, onOpen: r }) {
|
|
85
|
-
this.#e = r;
|
|
67
|
+
init({ url: e, data: t, keysSkipped: n, onOpen: i }) {
|
|
68
|
+
this.#e = i;
|
|
86
69
|
let a = this.shadowRoot, o = document.createElement("style");
|
|
87
|
-
o.textContent = "\n:host {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n height: 40px;\n padding: 0 8px;\n background: #222;\n font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;\n font-weight: 200;\n color: #fff;\n z-index: 2147483647;\n box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.1), inset 0 2px 6px rgba(0, 0, 0, 0.8);\n background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3));\n box-sizing: border-box;\n}\n\n
|
|
70
|
+
o.textContent = "\n:host {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n height: 40px;\n padding: 0 8px;\n background: #222;\n font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;\n font-weight: 200;\n color: #fff;\n z-index: 2147483647;\n box-shadow: 0 -1px 0 rgba(255, 255, 255, 0.1), inset 0 2px 6px rgba(0, 0, 0, 0.8);\n background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.3));\n box-sizing: border-box;\n}\n\n.log-menu {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 40px;\n max-height: calc(100vh - 40px);\n background: #222;\n color: #fff;\n overflow-y: auto;\n}\n\n.log-menu[hidden] {\n display: none;\n}\n\n.log-menu tbody td {\n padding: 2px 8px;\n}\n\n.log-menu tbody tr {\n cursor: pointer;\n}\n\n.log-menu tbody tr:hover {\n background: #444;\n}\n\n.log-menu tbody tr[hidden] {\n display: none;\n}\n\n.button {\n position: relative;\n display: inline-block;\n color: #fff;\n margin: 8px 1px;\n height: 24px;\n line-height: 24px;\n padding: 0 8px;\n font-size: 14px;\n cursor: pointer;\n vertical-align: middle;\n background-color: #444;\n background-image: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2));\n border-radius: 2px;\n box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.2),\n inset 0 0 2px rgba(255, 255, 255, 0.2);\n text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.4);\n text-decoration: none;\n}\n\n.button:hover,\n.button:focus {\n color: #fff;\n text-decoration: none;\n background-color: #555;\n}\n\n.notice {\n display: inline-block;\n margin: 8px;\n font-size: 13px;\n line-height: 24px;\n vertical-align: middle;\n color: #ffd24d;\n}\n\n.search {\n appearance: none;\n border: none;\n border-radius: 2px;\n background-image: linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0));\n box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.2), inset 0 0 2px rgba(0, 0, 0, 0.2);\n padding: 2px 8px;\n margin: 0;\n line-height: 20px;\n vertical-align: middle;\n color: black;\n width: auto;\n height: auto;\n font-size: 14px;\n}\n", a.append(o);
|
|
88
71
|
let s = this.makeButton("CopyTuner", e, "_blank"), c = this.makeButton("Sync", "/copytuner", "_blank"), l = this.makeButton("Translations in this page", "javascript:void(0)");
|
|
89
72
|
this.#t = document.createElement("input"), this.#t.type = "text", this.#t.classList.add("search"), this.#t.placeholder = "search", a.append(s, c, l, this.#t), this.#n = this.makeLogMenu(t), a.append(this.#n), n && this.appendSkippedNotice(), l.addEventListener("click", (e) => {
|
|
90
73
|
e.preventDefault(), this.toggleLogMenu();
|
|
91
|
-
}), this.#t.addEventListener("input",
|
|
74
|
+
}), this.#t.addEventListener("input", r(this.onSearch.bind(this), 250));
|
|
92
75
|
}
|
|
93
76
|
show() {
|
|
94
|
-
this
|
|
95
|
-
}
|
|
96
|
-
hide() {
|
|
97
|
-
this.hidden = !0;
|
|
77
|
+
this.#t.focus();
|
|
98
78
|
}
|
|
99
79
|
makeButton(e, t, n) {
|
|
100
80
|
let r = document.createElement("a");
|
|
@@ -135,30 +115,41 @@ var e = navigator.platform.toUpperCase().includes("MAC"), t = (e) => !!(e.offset
|
|
|
135
115
|
let t = [...this.#n.querySelectorAll("tr")];
|
|
136
116
|
for (let n of t) n.hidden = !(e === "" || [...n.querySelectorAll("td")].some((t) => (t.textContent ?? "").includes(e)));
|
|
137
117
|
}
|
|
118
|
+
}, a = class extends HTMLElement {
|
|
119
|
+
dialog;
|
|
120
|
+
#e = () => {};
|
|
121
|
+
constructor() {
|
|
122
|
+
super();
|
|
123
|
+
let e = this.attachShadow({ mode: "open" }), t = document.createElement("style");
|
|
124
|
+
t.textContent = "\n/* transform / filter / perspective / contain は置かない(理由は OVERLAY_STYLES のコメント参照) */\ndialog {\n position: fixed;\n inset: 0;\n width: 100%;\n height: 100%;\n max-width: none;\n max-height: none;\n margin: 0;\n border: none;\n padding: 0;\n background: transparent;\n color: inherit;\n}\n\ndialog::backdrop {\n background: transparent;\n}\n\n.toggle-button {\n display: block;\n position: fixed;\n left: 0;\n bottom: 0;\n color: white;\n background: black;\n padding: 12px 16px;\n border-radius: 0 10px 0 0;\n opacity: 0;\n transition: opacity 0.6s ease-in-out;\n z-index: 10000;\n font-size: 12px;\n cursor: pointer;\n text-decoration: none;\n}\n\n.toggle-button:hover {\n opacity: 1;\n}\n\n@media screen and (max-width: 480px) {\n .toggle-button {\n display: none;\n }\n}\n", e.append(t), this.dialog = document.createElement("dialog");
|
|
125
|
+
let n = document.createElement("a");
|
|
126
|
+
n.classList.add("toggle-button"), n.textContent = "Open CopyTuner", n.addEventListener("click", () => this.#e()), e.append(this.dialog, n);
|
|
127
|
+
}
|
|
128
|
+
set onToggle(e) {
|
|
129
|
+
this.#e = e;
|
|
130
|
+
}
|
|
138
131
|
};
|
|
139
|
-
customElements.define("copytuner-bar",
|
|
140
|
-
var
|
|
141
|
-
let { url:
|
|
142
|
-
document.body.append(a)
|
|
143
|
-
|
|
144
|
-
|
|
132
|
+
customElements.define("copytuner-bar", i), customElements.define("copyray-overlay", t), customElements.define("copytuner-root", a);
|
|
133
|
+
var o = () => {
|
|
134
|
+
let { url: e, data: t, keysSkipped: r } = window.CopyTuner, i = (t) => window.open(`${e}/blurbs/${t}/edit`), a = document.createElement("copytuner-root");
|
|
135
|
+
document.body.append(a);
|
|
136
|
+
let o = document.createElement("copytuner-bar"), s = document.createElement("copyray-overlay");
|
|
137
|
+
s.onOpen = i, a.dialog.append(s, o), o.init({
|
|
138
|
+
url: e,
|
|
139
|
+
data: t,
|
|
145
140
|
keysSkipped: !!r,
|
|
146
141
|
onOpen: i
|
|
147
142
|
});
|
|
148
|
-
let
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
},
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
(e && t.metaKey || !e && t.ctrlKey) && t.shiftKey && t.key.toLowerCase() === "k" && l();
|
|
161
|
-
}), console && console.log(`Ready to Copyray. Press ${e ? "cmd+shift+k" : "ctrl+shift+k"} to scan your UI.`);
|
|
143
|
+
let c = () => {
|
|
144
|
+
a.dialog.showModal(), s.show(), o.show();
|
|
145
|
+
}, l = () => {
|
|
146
|
+
a.dialog.close(), s.hide();
|
|
147
|
+
}, u = () => a.dialog.open ? l() : c();
|
|
148
|
+
a.onToggle = u, s.onClose = l, window.CopyTuner.toggle = u, a.dialog.addEventListener("close", () => {
|
|
149
|
+
a.dialog.open || s.hide();
|
|
150
|
+
}), document.addEventListener("keydown", (e) => {
|
|
151
|
+
(n && e.metaKey || !n && e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "k" && u();
|
|
152
|
+
}), console && console.log(`Ready to Copyray. Press ${n ? "cmd+shift+k" : "ctrl+shift+k"} to scan your UI.`);
|
|
162
153
|
};
|
|
163
|
-
document.readyState === "complete" || document.readyState !== "loading" ?
|
|
154
|
+
document.readyState === "complete" || document.readyState !== "loading" ? o() : document.addEventListener("DOMContentLoaded", () => o());
|
|
164
155
|
//#endregion
|
data/index.html
CHANGED
|
@@ -6,6 +6,35 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>CopyTuner Dev</title>
|
|
9
|
+
<style>
|
|
10
|
+
.fixed-banner {
|
|
11
|
+
position: fixed;
|
|
12
|
+
top: 0;
|
|
13
|
+
right: 0;
|
|
14
|
+
padding: 8px 12px;
|
|
15
|
+
background: #ffe9a8;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* width は横スクロール時の left 経路の検証用 */
|
|
19
|
+
.spacer {
|
|
20
|
+
height: 150vh;
|
|
21
|
+
width: 150vw;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* transform を持つ祖先は fixed 要素の containing block になり、viewport 基準からずれるケースの再現用 */
|
|
25
|
+
.transform-ancestor {
|
|
26
|
+
transform: translateZ(0);
|
|
27
|
+
height: 80px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.transformed-fixed {
|
|
31
|
+
position: fixed;
|
|
32
|
+
bottom: 0;
|
|
33
|
+
left: 0;
|
|
34
|
+
padding: 8px 12px;
|
|
35
|
+
background: #a8d8ff;
|
|
36
|
+
}
|
|
37
|
+
</style>
|
|
9
38
|
</head>
|
|
10
39
|
|
|
11
40
|
<body>
|
|
@@ -15,6 +44,23 @@
|
|
|
15
44
|
<li data-copyray-key="projects.index.blurbs">翻訳キー</li>
|
|
16
45
|
<li data-copyray-key="projects.index.members">メンバー数</li>
|
|
17
46
|
</ul>
|
|
47
|
+
|
|
48
|
+
<p class="fixed-banner" data-copyray-key="projects.index.fixed_banner">固定表示のバナー</p>
|
|
49
|
+
|
|
50
|
+
<div class="transform-ancestor">
|
|
51
|
+
<p class="transformed-fixed" data-copyray-key="projects.index.transformed_fixed">transform 祖先を持つ固定要素</p>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<button type="button" id="open-app-modal">アプリのモーダルを開く</button>
|
|
55
|
+
|
|
56
|
+
<dialog id="app-modal">
|
|
57
|
+
<p data-copyray-key="projects.index.modal_body">モーダル内の文言</p>
|
|
58
|
+
<button type="button" id="close-app-modal">閉じる</button>
|
|
59
|
+
</dialog>
|
|
60
|
+
|
|
61
|
+
<div class="spacer"></div>
|
|
62
|
+
|
|
63
|
+
<p data-copyray-key="projects.index.below_spacer">スクロール後に見える文言</p>
|
|
18
64
|
</div>
|
|
19
65
|
<script>
|
|
20
66
|
window.CopyTuner = {
|
|
@@ -23,8 +69,16 @@
|
|
|
23
69
|
'projects.index.locales': '言語',
|
|
24
70
|
'projects.index.blurbs': '翻訳キー',
|
|
25
71
|
'projects.index.members': 'メンバー数',
|
|
72
|
+
'projects.index.fixed_banner': '固定表示のバナー',
|
|
73
|
+
'projects.index.transformed_fixed': 'transform 祖先を持つ固定要素',
|
|
74
|
+
'projects.index.modal_body': 'モーダル内の文言',
|
|
75
|
+
'projects.index.below_spacer': 'スクロール後に見える文言',
|
|
26
76
|
},
|
|
27
77
|
}
|
|
78
|
+
|
|
79
|
+
const appModal = document.getElementById('app-modal')
|
|
80
|
+
document.getElementById('open-app-modal').addEventListener('click', () => appModal.showModal())
|
|
81
|
+
document.getElementById('close-app-modal').addEventListener('click', () => appModal.close())
|
|
28
82
|
</script>
|
|
29
83
|
<script type="module" src="/src/main.ts"></script>
|
|
30
84
|
</body>
|
|
@@ -3,6 +3,10 @@ require 'copy_tuner_client/copyray/marker'
|
|
|
3
3
|
module CopyTunerClient
|
|
4
4
|
# 翻訳テキストにオーバーレイ編集用のマーカートークンを埋め込む
|
|
5
5
|
class Copyray
|
|
6
|
+
# NOTE: マーカーは CopyrayMiddleware の Rewriter でしか除去されない。注入可否を「format が html か」ではなく
|
|
7
|
+
# 「この描画の出力が middleware を通るか」で判定するため、middleware が env にこの印を立てる。
|
|
8
|
+
ENV_KEY = 'copy_tuner.copyray_injectable'.freeze
|
|
9
|
+
|
|
6
10
|
# This:
|
|
7
11
|
# message
|
|
8
12
|
# Becomes:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# cf) xray-rails : xray/middleware.rb
|
|
2
2
|
|
|
3
|
+
require 'copy_tuner_client/copyray'
|
|
3
4
|
require 'copy_tuner_client/copyray/rewriter'
|
|
4
5
|
|
|
5
6
|
module CopyTunerClient
|
|
@@ -11,6 +12,7 @@ module CopyTunerClient
|
|
|
11
12
|
|
|
12
13
|
def call(env)
|
|
13
14
|
CopyTunerClient::TranslationLog.clear
|
|
15
|
+
env[CopyTunerClient::Copyray::ENV_KEY] = true
|
|
14
16
|
status, headers, response = @app.call(env)
|
|
15
17
|
if rewritable?(status, headers) && (body = response_body(response))
|
|
16
18
|
rewrite_response(env, status, headers, body, response)
|
|
@@ -17,6 +17,13 @@ module CopyTunerClient
|
|
|
17
17
|
)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
21
|
+
CopyTunerClient::HelperExtension.hook_render_to_string(
|
|
22
|
+
ActionController::Base,
|
|
23
|
+
middleware_enabled: CopyTunerClient.configuration.enable_middleware?
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
20
27
|
CopyTunerClient::TranslationLog.install_hook if CopyTunerClient.configuration.enable_middleware?
|
|
21
28
|
|
|
22
29
|
require 'copy_tuner_client/simple_form_extention'
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
require 'copy_tuner_client/copyray'
|
|
2
|
+
|
|
1
3
|
module CopyTunerClient
|
|
2
|
-
# ActionView の translate/t
|
|
4
|
+
# ActionView の translate/t を差し替えて Copyray のオーバーレイマーカーを注入し、注入してよい描画経路を制御する
|
|
3
5
|
module HelperExtension
|
|
4
6
|
# NOTE: class_eval ブロック内で def すると、ブロック内メソッドの複雑さが
|
|
5
7
|
# hook_translation_helper 自体の Metrics/AbcSize としてカウントされてしまうため、
|
|
@@ -50,14 +52,40 @@ module CopyTunerClient
|
|
|
50
52
|
# マーカー混入は実害が大きいため、controller の型で明示除外する。
|
|
51
53
|
return false if defined?(ActionMailer::Base) && current_controller.is_a?(ActionMailer::Base)
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
request = current_controller.request
|
|
56
|
+
return false unless passes_copyray_middleware?(request)
|
|
57
|
+
|
|
58
|
+
# NOTE: middleware を通っても render json: などは rewritable? に弾かれ書き換えられないため、
|
|
59
|
+
# format が html でない経路には注入しない。
|
|
60
|
+
request.format&.html? || false
|
|
56
61
|
end
|
|
57
62
|
private :copyray_injectable?
|
|
63
|
+
|
|
64
|
+
# NOTE: format が html でも出力が CopyrayMiddleware を通らない経路(ApplicationController.renderer や
|
|
65
|
+
# render_to_string)ではマーカーが除去されずに残るため、middleware が立てた印がある場合に限り注入する。
|
|
66
|
+
def passes_copyray_middleware?(request)
|
|
67
|
+
request&.env&.[](CopyTunerClient::Copyray::ENV_KEY)
|
|
68
|
+
end
|
|
69
|
+
private :passes_copyray_middleware?
|
|
58
70
|
end
|
|
59
71
|
private_constant :CopyrayCommentInjection
|
|
60
72
|
|
|
73
|
+
# NOTE: render_to_string の結果は PDF 化や JSON 埋め込みなどレスポンス以外に使われ、middleware の
|
|
74
|
+
# 書き換えを通らない。実行中だけ印を外し、その間の translate にマーカーを注入させない。
|
|
75
|
+
module RenderToStringGuard
|
|
76
|
+
def render_to_string(*, **, &)
|
|
77
|
+
env = request&.env
|
|
78
|
+
return super unless env&.delete(CopyTunerClient::Copyray::ENV_KEY)
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
super
|
|
82
|
+
ensure
|
|
83
|
+
env[CopyTunerClient::Copyray::ENV_KEY] = true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
private_constant :RenderToStringGuard
|
|
88
|
+
|
|
61
89
|
def self.hook_translation_helper(mod, middleware_enabled:)
|
|
62
90
|
return unless middleware_enabled
|
|
63
91
|
|
|
@@ -68,5 +96,11 @@ module CopyTunerClient
|
|
|
68
96
|
alias_method :t, :translate
|
|
69
97
|
end
|
|
70
98
|
end
|
|
99
|
+
|
|
100
|
+
def self.hook_render_to_string(mod, middleware_enabled:)
|
|
101
|
+
return unless middleware_enabled
|
|
102
|
+
|
|
103
|
+
mod.prepend(RenderToStringGuard)
|
|
104
|
+
end
|
|
71
105
|
end
|
|
72
106
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require 'spec_helper'
|
|
2
2
|
require 'copy_tuner_client/copyray_middleware'
|
|
3
3
|
require 'copy_tuner_client/copyray/marker'
|
|
4
|
+
require 'copy_tuner_client/copyray'
|
|
4
5
|
require 'copy_tuner_client/translation_log'
|
|
5
6
|
|
|
6
7
|
describe CopyTunerClient::CopyrayMiddleware do
|
|
@@ -78,6 +79,19 @@ describe CopyTunerClient::CopyrayMiddleware do
|
|
|
78
79
|
end
|
|
79
80
|
end
|
|
80
81
|
|
|
82
|
+
context '下流の app を呼ぶとき' do
|
|
83
|
+
it '下流の app に渡る env にフラグが立っている' do
|
|
84
|
+
received_env = nil
|
|
85
|
+
recording_app =
|
|
86
|
+
->(env) do
|
|
87
|
+
received_env = env.dup
|
|
88
|
+
[200, { 'Content-Type' => 'application/json' }, ['{}']]
|
|
89
|
+
end
|
|
90
|
+
described_class.new(recording_app).call({})
|
|
91
|
+
expect(received_env[CopyTunerClient::Copyray::ENV_KEY]).to be true
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
81
95
|
context 'HTML 以外のレスポンスのとき' do
|
|
82
96
|
let(:headers) { { 'Content-Type' => 'application/json' } }
|
|
83
97
|
let(:body) { "{\"x\":\"#{marker('a.b')}\"}" }
|
|
@@ -17,7 +17,12 @@ describe CopyTunerClient::HelperExtension do
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def request_class
|
|
20
|
-
@request_class ||= Struct.new(:format)
|
|
20
|
+
@request_class ||= Struct.new(:format, :env)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# NOTE: CopyrayMiddleware を通るリクエストを再現するため、フラグ付きの env を持つ request を作る。
|
|
24
|
+
def injectable_request(format = :html)
|
|
25
|
+
request_class.new(format_class.new(format), { CopyTunerClient::Copyray::ENV_KEY => true })
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
def controller_class
|
|
@@ -44,7 +49,7 @@ describe CopyTunerClient::HelperExtension do
|
|
|
44
49
|
# デフォルトはそれを再現した controller を持たせておく。
|
|
45
50
|
let(:view) do
|
|
46
51
|
Class.new.include(keyword_arguments_helper).new.tap do |v|
|
|
47
|
-
v.controller = controller_class.new(
|
|
52
|
+
v.controller = controller_class.new(injectable_request)
|
|
48
53
|
end
|
|
49
54
|
end
|
|
50
55
|
|
|
@@ -84,10 +89,20 @@ describe CopyTunerClient::HelperExtension do
|
|
|
84
89
|
|
|
85
90
|
%i[json text csv pdf].each do |format|
|
|
86
91
|
it "does not inject the marker when request.format is :#{format}" do
|
|
87
|
-
view.controller = controller_class.new(
|
|
92
|
+
view.controller = controller_class.new(injectable_request(format))
|
|
88
93
|
expect(view.translate('some.key', name: 'World')).to eq 'Hello, World'
|
|
89
94
|
end
|
|
90
95
|
end
|
|
96
|
+
|
|
97
|
+
it 'format が html でも env にフラグが無ければ注入しない' do
|
|
98
|
+
view.controller = controller_class.new(request_class.new(format_class.new(:html), {}))
|
|
99
|
+
expect(view.translate('some.key', name: 'World')).to eq 'Hello, World'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'request.env が nil でも落ちずに注入しない' do
|
|
103
|
+
view.controller = controller_class.new(request_class.new(format_class.new(:html), nil))
|
|
104
|
+
expect(view.translate('some.key', name: 'World')).to eq 'Hello, World'
|
|
105
|
+
end
|
|
91
106
|
end
|
|
92
107
|
|
|
93
108
|
context 'injection guard by controller' do
|
|
@@ -109,7 +124,7 @@ describe CopyTunerClient::HelperExtension do
|
|
|
109
124
|
|
|
110
125
|
it 'does not raise when ActionMailer is not loaded' do
|
|
111
126
|
hide_const('ActionMailer::Base') if defined?(ActionMailer::Base)
|
|
112
|
-
view.controller = controller_class.new(
|
|
127
|
+
view.controller = controller_class.new(injectable_request)
|
|
113
128
|
expect { view.translate('some.key', name: 'World') }.not_to raise_error
|
|
114
129
|
end
|
|
115
130
|
end
|
|
@@ -118,10 +133,91 @@ describe CopyTunerClient::HelperExtension do
|
|
|
118
133
|
# 維持されなければならない。注入ガードが初期値登録まで巻き添えで止めていないことを保証する。
|
|
119
134
|
context 'default value registration' do
|
|
120
135
|
it 'registers the default value even when the marker is not injected' do
|
|
121
|
-
view.controller = controller_class.new(
|
|
136
|
+
view.controller = controller_class.new(injectable_request(:json))
|
|
122
137
|
allow(I18n).to receive(:t)
|
|
123
138
|
view.translate('some.key', name: 'World', default: 'Default')
|
|
124
139
|
expect(I18n).to have_received(:t).with('some.key', hash_including(default: 'Default'))
|
|
125
140
|
end
|
|
126
141
|
end
|
|
142
|
+
|
|
143
|
+
describe '.hook_render_to_string' do
|
|
144
|
+
# NOTE: ActionController::Base を読み込まずに検証するため、super 呼び出し時の env の状態を
|
|
145
|
+
# 記録するだけの最小の基底クラスに prepend する。
|
|
146
|
+
let(:base_class) do
|
|
147
|
+
Class.new do
|
|
148
|
+
attr_reader :request, :recorded
|
|
149
|
+
|
|
150
|
+
def initialize(request)
|
|
151
|
+
@request = request
|
|
152
|
+
@recorded = []
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def render_to_string(*_args, raise_error: false, nested: false)
|
|
156
|
+
record_flag
|
|
157
|
+
if nested
|
|
158
|
+
render_to_string
|
|
159
|
+
record_flag
|
|
160
|
+
end
|
|
161
|
+
raise 'boom' if raise_error
|
|
162
|
+
|
|
163
|
+
'rendered'
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def record_flag
|
|
167
|
+
@recorded << request&.env&.key?(CopyTunerClient::Copyray::ENV_KEY)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
let(:env) { { CopyTunerClient::Copyray::ENV_KEY => true } }
|
|
172
|
+
let(:controller) { base_class.new(request_class.new(format_class.new(:html), env)) }
|
|
173
|
+
|
|
174
|
+
context 'middleware_enabled が true のとき' do
|
|
175
|
+
before { described_class.hook_render_to_string(base_class, middleware_enabled: true) }
|
|
176
|
+
|
|
177
|
+
it '実行中は env からフラグが外れる' do
|
|
178
|
+
controller.render_to_string
|
|
179
|
+
expect(controller.recorded).to eq [false]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it '元の戻り値を返す' do
|
|
183
|
+
expect(controller.render_to_string).to eq 'rendered'
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it '終了後はフラグが復元される' do
|
|
187
|
+
controller.render_to_string
|
|
188
|
+
expect(env[CopyTunerClient::Copyray::ENV_KEY]).to be true
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it '例外が起きてもフラグが復元される' do
|
|
192
|
+
expect { controller.render_to_string(raise_error: true) }.to raise_error('boom')
|
|
193
|
+
expect(env[CopyTunerClient::Copyray::ENV_KEY]).to be true
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it '入れ子でも外側の終了時まで外れたまま' do
|
|
197
|
+
controller.render_to_string(nested: true)
|
|
198
|
+
expect(controller.recorded).to eq [false, false, false]
|
|
199
|
+
expect(env[CopyTunerClient::Copyray::ENV_KEY]).to be true
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it '元々フラグが無い env にはフラグを足さない' do
|
|
203
|
+
env.clear
|
|
204
|
+
controller.render_to_string
|
|
205
|
+
expect(env).not_to have_key(CopyTunerClient::Copyray::ENV_KEY)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'request が nil でも落ちない' do
|
|
209
|
+
controller = base_class.new(nil)
|
|
210
|
+
expect(controller.render_to_string).to eq 'rendered'
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
context 'middleware_enabled が false のとき' do
|
|
215
|
+
before { described_class.hook_render_to_string(base_class, middleware_enabled: false) }
|
|
216
|
+
|
|
217
|
+
it 'フックせず実行中もフラグが残る' do
|
|
218
|
+
controller.render_to_string
|
|
219
|
+
expect(controller.recorded).to eq [true]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
127
223
|
end
|
data/src/copyray-overlay.ts
CHANGED
|
@@ -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
|
-
//
|
|
17
|
+
// オーバーレイ背景と翻訳要素のハイライト枠(specimen)を Shadow DOM 内に描画する。
|
|
19
18
|
export class CopyrayOverlay extends HTMLElement {
|
|
20
19
|
#onOpen: OpenCallback = () => {}
|
|
21
|
-
#
|
|
20
|
+
#onClose: () => void = () => {}
|
|
22
21
|
#backdrop: HTMLDivElement
|
|
23
22
|
#specimens: HTMLDivElement
|
|
24
|
-
#
|
|
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
|
|
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.#
|
|
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
|
|
59
|
-
this.#
|
|
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
|
-
|
|
68
|
-
this
|
|
52
|
+
// 二重 show でもリスナーと boxes が重複しないよう、まず後始末を通す
|
|
53
|
+
this.hide()
|
|
69
54
|
|
|
70
55
|
for (const { element, keys } of findBlurbs()) {
|
|
71
|
-
|
|
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
|
-
|
|
80
|
-
this.#
|
|
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
|
-
|
|
77
|
+
// detached な box を握り続けると開くたびに肥大する
|
|
78
|
+
this.#boxes.clear()
|
|
84
79
|
this.#specimens.replaceChildren()
|
|
85
80
|
}
|
|
86
81
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
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
|
+
}
|
data/src/main.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* eslint-disable no-console */
|
|
2
2
|
import { CopyrayOverlay } from './copyray-overlay'
|
|
3
3
|
import { CopytunerBar } from './copytuner-bar'
|
|
4
|
+
import { CopytunerRoot } from './copytuner-root'
|
|
4
5
|
import { isMac } from './util'
|
|
5
6
|
|
|
6
7
|
declare global {
|
|
@@ -18,38 +19,47 @@ declare global {
|
|
|
18
19
|
|
|
19
20
|
customElements.define('copytuner-bar', CopytunerBar)
|
|
20
21
|
customElements.define('copyray-overlay', CopyrayOverlay)
|
|
22
|
+
customElements.define('copytuner-root', CopytunerRoot)
|
|
21
23
|
|
|
22
24
|
const start = () => {
|
|
23
25
|
const { url, data, keysSkipped } = window.CopyTuner
|
|
24
26
|
const onOpen = (key: string) => window.open(`${url}/blurbs/${key}/edit`)
|
|
25
27
|
|
|
26
|
-
const
|
|
27
|
-
document.body.append(
|
|
28
|
-
bar.init({ url, data, keysSkipped: Boolean(keysSkipped), onOpen })
|
|
28
|
+
const root = document.createElement('copytuner-root') as CopytunerRoot
|
|
29
|
+
document.body.append(root)
|
|
29
30
|
|
|
31
|
+
const bar = document.createElement('copytuner-bar') as CopytunerBar
|
|
30
32
|
const overlay = document.createElement('copyray-overlay') as CopyrayOverlay
|
|
31
33
|
overlay.onOpen = onOpen
|
|
32
|
-
|
|
34
|
+
root.dialog.append(overlay, bar)
|
|
35
|
+
bar.init({ url, data, keysSkipped: Boolean(keysSkipped), onOpen })
|
|
33
36
|
|
|
34
37
|
const show = () => {
|
|
38
|
+
// display: none のままでは searchBox にフォーカスが当たらないので dialog を先に開く
|
|
39
|
+
root.dialog.showModal()
|
|
35
40
|
overlay.show()
|
|
36
41
|
bar.show()
|
|
37
42
|
}
|
|
38
43
|
const hide = () => {
|
|
44
|
+
root.dialog.close()
|
|
45
|
+
// close イベントを待たず同期で後始末する。イベントは queued task 発火で、
|
|
46
|
+
// 自動テスト環境など発火が遅れる/落ちる状況があるため、ここを唯一の頼りにしない
|
|
39
47
|
overlay.hide()
|
|
40
|
-
bar.hide()
|
|
41
48
|
}
|
|
42
|
-
const toggle = () => (
|
|
49
|
+
const toggle = () => (root.dialog.open ? hide() : show())
|
|
43
50
|
|
|
44
|
-
|
|
51
|
+
root.onToggle = toggle
|
|
52
|
+
overlay.onClose = hide
|
|
45
53
|
window.CopyTuner.toggle = toggle
|
|
46
54
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
// Escape のようにこちらの hide() を経由しない閉じ方の受け皿。overlay.hide() は冪等なので二重でも問題ない
|
|
56
|
+
root.dialog.addEventListener('close', () => {
|
|
57
|
+
// 閉じた直後に開き直された場合、遅れて届いたこのイベントで後始末してはいけない
|
|
58
|
+
if (root.dialog.open) return
|
|
59
|
+
overlay.hide()
|
|
60
|
+
})
|
|
52
61
|
|
|
62
|
+
document.addEventListener('keydown', (event) => {
|
|
53
63
|
if (((isMac && event.metaKey) || (!isMac && event.ctrlKey)) && event.shiftKey && event.key.toLowerCase() === 'k') {
|
|
54
64
|
toggle()
|
|
55
65
|
}
|
data/src/styles.ts
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
// 各 custom element の Shadow root に <style> として注入する CSS。
|
|
2
2
|
// Shadow DOM のスタイル隔離が効くため、旧 copyray.css にあった #copyray-overlay * のグローバルリセットは不要。
|
|
3
3
|
|
|
4
|
+
// コンテナ(<copytuner-root>)のスタイル。
|
|
5
|
+
// dialog の UA デフォルト(中央寄せ・白背景・枠線・padding・暗転)を全て打ち消し、
|
|
6
|
+
// viewport 全面を覆う透明なコンテナにする。暗転は overlay 側の .backdrop が担う。
|
|
7
|
+
export const ROOT_STYLES = `
|
|
8
|
+
/* transform / filter / perspective / contain は置かない(理由は OVERLAY_STYLES のコメント参照) */
|
|
9
|
+
dialog {
|
|
10
|
+
position: fixed;
|
|
11
|
+
inset: 0;
|
|
12
|
+
width: 100%;
|
|
13
|
+
height: 100%;
|
|
14
|
+
max-width: none;
|
|
15
|
+
max-height: none;
|
|
16
|
+
margin: 0;
|
|
17
|
+
border: none;
|
|
18
|
+
padding: 0;
|
|
19
|
+
background: transparent;
|
|
20
|
+
color: inherit;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
dialog::backdrop {
|
|
24
|
+
background: transparent;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.toggle-button {
|
|
28
|
+
display: block;
|
|
29
|
+
position: fixed;
|
|
30
|
+
left: 0;
|
|
31
|
+
bottom: 0;
|
|
32
|
+
color: white;
|
|
33
|
+
background: black;
|
|
34
|
+
padding: 12px 16px;
|
|
35
|
+
border-radius: 0 10px 0 0;
|
|
36
|
+
opacity: 0;
|
|
37
|
+
transition: opacity 0.6s ease-in-out;
|
|
38
|
+
z-index: 10000;
|
|
39
|
+
font-size: 12px;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
text-decoration: none;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.toggle-button:hover {
|
|
45
|
+
opacity: 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@media screen and (max-width: 480px) {
|
|
49
|
+
.toggle-button {
|
|
50
|
+
display: none;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
`
|
|
54
|
+
|
|
4
55
|
// ツールバー(<copytuner-bar>)のスタイル。:host にバー本体のレイアウトを定義する。
|
|
5
56
|
export const BAR_STYLES = `
|
|
6
57
|
:host {
|
|
@@ -20,10 +71,6 @@ export const BAR_STYLES = `
|
|
|
20
71
|
box-sizing: border-box;
|
|
21
72
|
}
|
|
22
73
|
|
|
23
|
-
:host([hidden]) {
|
|
24
|
-
display: none;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
74
|
.log-menu {
|
|
28
75
|
position: fixed;
|
|
29
76
|
left: 0;
|
|
@@ -109,22 +156,19 @@ export const BAR_STYLES = `
|
|
|
109
156
|
`
|
|
110
157
|
|
|
111
158
|
// オーバーレイ(<copyray-overlay>)のスタイル。
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
159
|
+
// specimen は getBoundingClientRect() の viewport 座標をそのまま使うため、
|
|
160
|
+
// dialog / :host / specimen のコンテナ div のいずれにも
|
|
161
|
+
// transform / filter / perspective / contain を置かない。
|
|
162
|
+
// 置くとそれが containing block になり、fixed な specimen が viewport 基準から外れる。
|
|
163
|
+
// :host の position: absolute と width/height: 0 は、インラインの custom element が
|
|
164
|
+
// dialog のフローに line box を作らないようにするため(fixed の containing block は作らない)。
|
|
115
165
|
export const OVERLAY_STYLES = `
|
|
116
166
|
:host {
|
|
117
167
|
position: absolute;
|
|
118
|
-
top: 0;
|
|
119
|
-
left: 0;
|
|
120
168
|
width: 0;
|
|
121
169
|
height: 0;
|
|
122
170
|
}
|
|
123
171
|
|
|
124
|
-
:host([hidden]) {
|
|
125
|
-
display: none;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
172
|
.backdrop {
|
|
129
173
|
position: fixed;
|
|
130
174
|
inset: 0;
|
|
@@ -137,7 +181,7 @@ export const OVERLAY_STYLES = `
|
|
|
137
181
|
}
|
|
138
182
|
|
|
139
183
|
.specimen {
|
|
140
|
-
position:
|
|
184
|
+
position: fixed;
|
|
141
185
|
background: rgba(255, 50, 50, 0.1);
|
|
142
186
|
outline: 1px solid rgba(255, 50, 50, 0.8);
|
|
143
187
|
outline-offset: -1px;
|
|
@@ -153,6 +197,10 @@ export const OVERLAY_STYLES = `
|
|
|
153
197
|
background: rgba(255, 50, 50, 0.4);
|
|
154
198
|
}
|
|
155
199
|
|
|
200
|
+
.specimen[hidden] {
|
|
201
|
+
display: none;
|
|
202
|
+
}
|
|
203
|
+
|
|
156
204
|
.specimen-handle {
|
|
157
205
|
float: left;
|
|
158
206
|
margin: 0 2px 2px 0;
|
|
@@ -162,31 +210,4 @@ export const OVERLAY_STYLES = `
|
|
|
162
210
|
font-size: 10px;
|
|
163
211
|
cursor: pointer;
|
|
164
212
|
}
|
|
165
|
-
|
|
166
|
-
.toggle-button {
|
|
167
|
-
display: block;
|
|
168
|
-
position: fixed;
|
|
169
|
-
left: 0;
|
|
170
|
-
bottom: 0;
|
|
171
|
-
color: white;
|
|
172
|
-
background: black;
|
|
173
|
-
padding: 12px 16px;
|
|
174
|
-
border-radius: 0 10px 0 0;
|
|
175
|
-
opacity: 0;
|
|
176
|
-
transition: opacity 0.6s ease-in-out;
|
|
177
|
-
z-index: 10000;
|
|
178
|
-
font-size: 12px;
|
|
179
|
-
cursor: pointer;
|
|
180
|
-
text-decoration: none;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
.toggle-button:hover {
|
|
184
|
-
opacity: 1;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
@media screen and (max-width: 480px) {
|
|
188
|
-
.toggle-button {
|
|
189
|
-
display: none;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
213
|
`
|
data/src/util.ts
CHANGED
|
@@ -1,40 +1,5 @@
|
|
|
1
1
|
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
|
2
2
|
|
|
3
|
-
// @ts-expect-error TS7006
|
|
4
|
-
const isVisible = (element) => !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length > 0)
|
|
5
|
-
|
|
6
|
-
// @ts-expect-error TS7006
|
|
7
|
-
const getOffset = (elment) => {
|
|
8
|
-
const box = elment.getBoundingClientRect()
|
|
9
|
-
|
|
10
|
-
return {
|
|
11
|
-
top: box.top + (window.pageYOffset - document.documentElement.clientTop),
|
|
12
|
-
left: box.left + (window.pageXOffset - document.documentElement.clientLeft),
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// @ts-expect-error TS7006
|
|
17
|
-
const computeBoundingBox = (element) => {
|
|
18
|
-
if (!isVisible(element)) {
|
|
19
|
-
return null
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const boxFrame = getOffset(element)
|
|
23
|
-
// @ts-expect-error TS2339
|
|
24
|
-
boxFrame.right = boxFrame.left + element.offsetWidth
|
|
25
|
-
// @ts-expect-error TS2339
|
|
26
|
-
boxFrame.bottom = boxFrame.top + element.offsetHeight
|
|
27
|
-
|
|
28
|
-
return {
|
|
29
|
-
left: boxFrame.left,
|
|
30
|
-
top: boxFrame.top,
|
|
31
|
-
// @ts-expect-error TS2339
|
|
32
|
-
width: boxFrame.right - boxFrame.left,
|
|
33
|
-
// @ts-expect-error TS2339
|
|
34
|
-
height: boxFrame.bottom - boxFrame.top,
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
3
|
const debounce = <A extends unknown[]>(fn: (...args: A) => void, wait: number) => {
|
|
39
4
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
40
5
|
return (...args: A) => {
|
|
@@ -43,4 +8,4 @@ const debounce = <A extends unknown[]>(fn: (...args: A) => void, wait: number) =
|
|
|
43
8
|
}
|
|
44
9
|
}
|
|
45
10
|
|
|
46
|
-
export {
|
|
11
|
+
export { debounce, isMac }
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: copy_tuner_client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- SonicGarden
|
|
@@ -277,6 +277,7 @@ files:
|
|
|
277
277
|
- spec/support/writing_cache.rb
|
|
278
278
|
- src/copyray-overlay.ts
|
|
279
279
|
- src/copytuner-bar.ts
|
|
280
|
+
- src/copytuner-root.ts
|
|
280
281
|
- src/main.ts
|
|
281
282
|
- src/styles.ts
|
|
282
283
|
- src/util.ts
|
|
@@ -306,7 +307,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
306
307
|
- !ruby/object:Gem::Version
|
|
307
308
|
version: '0'
|
|
308
309
|
requirements: []
|
|
309
|
-
rubygems_version: 4.0.
|
|
310
|
+
rubygems_version: 4.0.10
|
|
310
311
|
specification_version: 4
|
|
311
312
|
summary: Client for the CopyTuner copy management service
|
|
312
313
|
test_files: []
|