Skip to content

Commit 6e241b1

Browse files
authored
Merge pull request #172 from takker99:text-input
feat(browser): Keep listeners registered to `#text-input`
2 parents 2b33809 + dcec523 commit 6e241b1

File tree

4 files changed

+218
-0
lines changed

4 files changed

+218
-0
lines changed

browser/dom/_internal.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { assertEquals } from "../../deps/testing.ts";
2+
import { decode, encode } from "./_internal.ts";
3+
4+
Deno.test("encode()", async (t) => {
5+
await t.step("should return 0 when options is undefined", () => {
6+
const result = encode(undefined);
7+
assertEquals(result, 0);
8+
});
9+
10+
await t.step("should return 1 when options.capture is true", () => {
11+
const options = { capture: true };
12+
const result = encode(options);
13+
assertEquals(result, 1);
14+
});
15+
16+
await t.step("should return 2 when options.once is true", () => {
17+
const options = { once: true };
18+
const result = encode(options);
19+
assertEquals(result, 2);
20+
});
21+
22+
await t.step("should return 4 when options.passive is true", () => {
23+
const options = { passive: true };
24+
const result = encode(options);
25+
assertEquals(result, 4);
26+
});
27+
28+
await t.step("should return 7 when all options are true", () => {
29+
const options = { capture: true, once: true, passive: true };
30+
const result = encode(options);
31+
assertEquals(result, 7);
32+
});
33+
34+
await t.step("should return 0 when options is false", () => {
35+
const result = encode(false);
36+
assertEquals(result, 0);
37+
});
38+
39+
await t.step("should return 1 when options is true", () => {
40+
const result = encode(true);
41+
assertEquals(result, 1);
42+
});
43+
});
44+
Deno.test("decode()", async (t) => {
45+
await t.step("should return undefined when encoded is 0", () => {
46+
const result = decode(0);
47+
assertEquals(result, undefined);
48+
});
49+
50+
await t.step("should return options with capture when encoded is 1", () => {
51+
const encoded = 1;
52+
const result = decode(encoded);
53+
assertEquals(result, { capture: true });
54+
});
55+
56+
await t.step("should return options with once when encoded is 2", () => {
57+
const encoded = 2;
58+
const result = decode(encoded);
59+
assertEquals(result, { once: true });
60+
});
61+
62+
await t.step("should return options with passive when encoded is 4", () => {
63+
const encoded = 4;
64+
const result = decode(encoded);
65+
assertEquals(result, { passive: true });
66+
});
67+
68+
await t.step("should return options with all flags when encoded is 7", () => {
69+
const encoded = 7;
70+
const result = decode(encoded);
71+
assertEquals(result, { capture: true, once: true, passive: true });
72+
});
73+
});

browser/dom/_internal.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/** 等値比較用に`AddEventListenerOptions`をencodeする */
2+
export const encode = (
3+
options: AddEventListenerOptions | boolean | undefined,
4+
): number => {
5+
if (options === undefined) return 0;
6+
if (typeof options === "boolean") return Number(options);
7+
// 各フラグをビットにエンコードする
8+
return (
9+
(options.capture ? 1 : 0) |
10+
(options.once ? 2 : 0) |
11+
(options.passive ? 4 : 0)
12+
);
13+
};
14+
/** 等値比較用にencodeした`AddEventListenerOptions`をdecodeする
15+
*
16+
* - `capture`: `0b001`
17+
* - `once`: `0b010`
18+
* - `passive`: `0b100`
19+
* - `0`: `undefined`
20+
*
21+
* @param encoded `AddEventListenerOptions`をencodeした値
22+
* @returns `AddEventListenerOptions`または`undefined`
23+
*/
24+
export const decode = (
25+
encoded: number,
26+
): AddEventListenerOptions | undefined => {
27+
if (encoded === 0) return;
28+
const options: AddEventListenerOptions = {};
29+
if (encoded & 1) options.capture = true;
30+
if (encoded & 2) options.once = true;
31+
if (encoded & 4) options.passive = true;
32+
33+
return options;
34+
};

browser/dom/mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export * from "./stores.ts";
1414
export * from "./takeInternalLines.ts";
1515
export * from "./pushPageTransition.ts";
1616
export * from "./extractCodeFiles.ts";
17+
export * from "./textInputEventListener.ts";

browser/dom/textInputEventListener.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { Scrapbox } from "../../deps/scrapbox.ts";
2+
import { textInput } from "./dom.ts";
3+
import { decode, encode } from "./_internal.ts";
4+
declare const scrapbox: Scrapbox;
5+
6+
/** - first key: event name
7+
* - second key: listener
8+
* - value: encoded options
9+
*/
10+
const listenerMap = new Map<
11+
keyof HTMLElementEventMap,
12+
Map<EventListener, Set<number>>
13+
>();
14+
const onceListenerMap = new Map<EventListener, Map<number, EventListener>>();
15+
16+
/** `#text-input`に対してイベントリスナーを追加する
17+
*
18+
* `#text-input`はページレイアウトが変わると削除されるため、登録したイベントリスナーの記憶と再登録をこの関数で行っている
19+
*
20+
* @param name event name
21+
* @param listener event listener
22+
* @param options event listener options
23+
* @returns
24+
*/
25+
export const addTextInputEventListener = <K extends keyof HTMLElementEventMap>(
26+
name: K,
27+
listener: (
28+
this: HTMLTextAreaElement,
29+
event: HTMLElementEventMap[K],
30+
) => unknown,
31+
options?: boolean | AddEventListenerOptions,
32+
): void => {
33+
const argMap = listenerMap.get(name) ?? new Map<EventListener, Set<number>>();
34+
const encodedOptions = argMap.get(listener as EventListener) ?? new Set();
35+
if (encodedOptions.has(encode(options))) return;
36+
encodedOptions.add(encode(options));
37+
argMap.set(listener as EventListener, encodedOptions);
38+
listenerMap.set(name, argMap);
39+
if (typeof options === "object" && options?.once) {
40+
const onceMap = onceListenerMap.get(listener as EventListener) ??
41+
new Map<number, EventListener>();
42+
const encoded = encode(options);
43+
44+
/** 呼び出し時に、`listenerMap`からの登録も解除するwrapper listener */
45+
const onceListener = function (
46+
this: HTMLTextAreaElement,
47+
event: HTMLElementEventMap[K],
48+
) {
49+
removeTextInputEventListener(name, listener, options);
50+
onceMap.delete(encoded);
51+
return listener.call(this, event);
52+
};
53+
onceMap.set(encoded, onceListener as EventListener);
54+
onceListenerMap.set(listener as EventListener, onceMap);
55+
56+
const textinput = textInput();
57+
if (!textinput) return;
58+
textinput.addEventListener<K>(name, onceListener, options);
59+
}
60+
const textinput = textInput();
61+
if (!textinput) return;
62+
textinput.addEventListener<K>(name, listener, options);
63+
};
64+
65+
// re-register event listeners when the layout changes
66+
scrapbox.on("layout:changed", () => {
67+
const textinput = textInput();
68+
if (!textinput) return;
69+
for (const [name, argMap] of listenerMap) {
70+
for (const [listener, encodedOptions] of argMap) {
71+
for (const encoded of encodedOptions) {
72+
textinput.addEventListener(
73+
name,
74+
listener as EventListener,
75+
decode(encoded),
76+
);
77+
}
78+
}
79+
}
80+
});
81+
82+
export const removeTextInputEventListener = <
83+
K extends keyof HTMLElementEventMap,
84+
>(
85+
name: K,
86+
listener: (event: HTMLElementEventMap[K]) => unknown,
87+
options?: boolean | AddEventListenerOptions,
88+
): void => {
89+
const argMap = listenerMap.get(name);
90+
if (!argMap) return;
91+
const encodedOptions = argMap.get(listener as EventListener);
92+
if (!encodedOptions) return;
93+
const encoded = encode(options);
94+
encodedOptions.delete(encoded);
95+
if (typeof options === "object" && options?.once) {
96+
const onceMap = onceListenerMap.get(listener as EventListener);
97+
if (!onceMap) return;
98+
const onceListener = onceMap.get(encoded);
99+
if (!onceListener) return;
100+
101+
const textinput = textInput();
102+
if (!textinput) return;
103+
textinput.removeEventListener(name, onceListener, options);
104+
onceMap.delete(encoded);
105+
return;
106+
}
107+
const textinput = textInput();
108+
if (!textinput) return;
109+
textinput.removeEventListener(name, listener, options);
110+
};

0 commit comments

Comments
 (0)