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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b141908e7fa8f6702cf9fccc712b88440af9aa96cc25c8b7875b3269d199825
4
- data.tar.gz: c7b3f527ba59c387f721364fe0e2689fd849b99b33f4e53557611f23af828794
3
+ metadata.gz: 938a08aeb03719e98a6e118eb714b820798f91ff3d5e69cce65eeea3d8b243ca
4
+ data.tar.gz: 9da7bbe8fa7ed6b92b90fa090f9ebf5ec67cb8945bace845494aa7638640b3ce
5
5
  SHA512:
6
- metadata.gz: cae2e31ecad418c08b1143f57bd5acf83b574ae5ca4aac1526e594ce911e7d37c98b7098a5a4d1bcaa4cacc133368764712525d80ef63b7dac4390bed5858093
7
- data.tar.gz: 06e3b11bb99dc6162a0a1110999e1fe2ab4bc3dde42cecfa8ecbe0656a0861b7ea17f659c0ef1791f4df1601cd10e7b465eac755d2ae5305d22e78e0ffe260c4
6
+ metadata.gz: 86d05a06a42a06da4586d60bc80649cdb9f4cd98c26425d6528f7b121c2ca880b366bc12aeec7b20952bbc7129b2837dcd9261851bb960ddebd7de773e0b64b6
7
+ data.tar.gz: 831783835b4ede6b590dd298e89f596c8c9ca417260e4ae490f8b6bbca576e07c7b5f89a490eb8b2b651e305a66b0c9dac7c526f0592d2fdef7f7337d70f6607
@@ -1,69 +1,50 @@
1
1
  //#region src/styles.ts
2
- var e = navigator.platform.toUpperCase().includes("MAC"), t = (e) => !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length > 0), n = (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
- })), o = class extends HTMLElement {
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 top: 0;\n left: 0;\n width: 0;\n height: 0;\n}\n\n:host([hidden]) {\n display: none;\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: absolute;\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-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\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.#n = document.createElement("div"), this.#n.classList.add("backdrop"), this.#n.addEventListener("click", () => this.hide()), this.#r = document.createElement("div"), this.#r.classList.add("specimens"), this.#i = document.createElement("a"), this.#i.classList.add("toggle-button"), this.#i.textContent = "Open CopyTuner", this.#i.addEventListener("click", () => this.#t()), e.append(this.#n, this.#r, this.#i), this.hide();
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 onToggle(e) {
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.reset(), this.#n.hidden = !1;
47
- for (let { element: e, keys: t } of a()) {
48
- let n = this.makeBox(e, t);
49
- n && this.#r.append(n);
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.reset(), this.#n.hidden = !0;
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
- reset() {
56
- this.#r.replaceChildren();
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, t) {
59
- let n = r(e);
60
- if (n === null) return null;
61
- let i = document.createElement("div");
62
- i.classList.add("specimen"), i.style.left = `${n.left}px`, i.style.top = `${n.top}px`, i.style.width = `${n.width}px`, i.style.height = `${n.height}px`;
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
- }, s = class extends HTMLElement {
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
- connectedCallback() {
82
- this.hidden = !0;
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:host([hidden]) {\n display: none;\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);
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", i(this.onSearch.bind(this), 250));
74
+ }), this.#t.addEventListener("input", r(this.onSearch.bind(this), 250));
92
75
  }
93
76
  show() {
94
- this.hidden = !1, this.#t.focus();
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", s), customElements.define("copyray-overlay", o);
140
- var c = () => {
141
- let { url: t, data: n, keysSkipped: r } = window.CopyTuner, i = (e) => window.open(`${t}/blurbs/${e}/edit`), a = document.createElement("copytuner-bar");
142
- document.body.append(a), a.init({
143
- url: t,
144
- data: n,
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 o = document.createElement("copyray-overlay");
149
- o.onOpen = i, document.body.append(o);
150
- let s = () => {
151
- o.show(), a.show();
152
- }, c = () => {
153
- o.hide(), a.hide();
154
- }, l = () => o.isShowing ? c() : s();
155
- o.onToggle = l, window.CopyTuner.toggle = l, document.addEventListener("keydown", (t) => {
156
- if (o.isShowing && ["Escape", "Esc"].includes(t.key)) {
157
- c();
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" ? c() : document.addEventListener("DOMContentLoaded", () => c());
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 を alias_method で差し替え、Copyray のオーバーレイマーカーを注入する
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
- # NOTE: request が無い/format が html でない経路には注入しない。&. と || false で
54
- # request 不在時も安全に false を返す。
55
- current_controller.request&.format&.html? || false
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,6 @@
1
1
  module CopyTunerClient
2
2
  # Client version
3
- VERSION = '2.3.0'.freeze
3
+ VERSION = '2.5.0'.freeze
4
4
 
5
5
  # API version being used to communicate with the server
6
6
  API_VERSION = '2.0'.freeze
@@ -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(request_class.new(format_class.new(:html)))
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(request_class.new(format_class.new(format)))
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(request_class.new(format_class.new(:html)))
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(request_class.new(format_class.new(:json)))
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
@@ -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
+ }
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 bar = document.createElement('copytuner-bar') as CopytunerBar
27
- document.body.append(bar)
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
- document.body.append(overlay)
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 = () => (overlay.isShowing ? hide() : show())
49
+ const toggle = () => (root.dialog.open ? hide() : show())
43
50
 
44
- overlay.onToggle = toggle
51
+ root.onToggle = toggle
52
+ overlay.onClose = hide
45
53
  window.CopyTuner.toggle = toggle
46
54
 
47
- document.addEventListener('keydown', (event) => {
48
- if (overlay.isShowing && ['Escape', 'Esc'].includes(event.key)) {
49
- hide()
50
- return
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
- // :host はドキュメント原点基準(position: absolute; top/left: 0)にして、
113
- // 子の specimen を computeBoundingBox のページ座標で absolute 配置できるようにする。
114
- // 背景の暗転(.backdrop)だけは viewport 固定(fixed)にする。
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: absolute;
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 { computeBoundingBox, debounce, getOffset, isMac, isVisible }
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.3.0
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.15
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: []