From 3182bf8621d5fef8f2e2fa0d306ff5660d971329 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Fri, 24 May 2024 16:55:21 +0900 Subject: [PATCH 1/2] feat(browser): Keep listeners registered to `#text-input` --- browser/dom/_internal.test.ts | 73 +++++++++++++++++ browser/dom/_internal.ts | 34 ++++++++ browser/dom/mod.ts | 1 + browser/dom/textInputEventListener.ts | 110 ++++++++++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 browser/dom/_internal.test.ts create mode 100644 browser/dom/_internal.ts create mode 100644 browser/dom/textInputEventListener.ts diff --git a/browser/dom/_internal.test.ts b/browser/dom/_internal.test.ts new file mode 100644 index 0000000..39bbd01 --- /dev/null +++ b/browser/dom/_internal.test.ts @@ -0,0 +1,73 @@ +import { assertEquals } from "../../deps/testing.ts"; +import { decode, encode } from "./_internal.ts"; + +Deno.test("encode()", async (t) => { + await t.step("should return 0 when options is undefined", () => { + const result = encode(undefined); + assertEquals(result, 0); + }); + + await t.step("should return 1 when options.capture is true", () => { + const options = { capture: true }; + const result = encode(options); + assertEquals(result, 1); + }); + + await t.step("should return 2 when options.once is true", () => { + const options = { once: true }; + const result = encode(options); + assertEquals(result, 2); + }); + + await t.step("should return 4 when options.passive is true", () => { + const options = { passive: true }; + const result = encode(options); + assertEquals(result, 4); + }); + + await t.step("should return 7 when all options are true", () => { + const options = { capture: true, once: true, passive: true }; + const result = encode(options); + assertEquals(result, 7); + }); + + await t.step("should return 0 when options is false", () => { + const result = encode(false); + assertEquals(result, 0); + }); + + await t.step("should return 1 when options is true", () => { + const result = encode(true); + assertEquals(result, 1); + }); +}); +Deno.test("decode()", async (t) => { + await t.step("should return undefined when encoded is 0", () => { + const result = decode(0); + assertEquals(result, undefined); + }); + + await t.step("should return options with capture when encoded is 1", () => { + const encoded = 1; + const result = decode(encoded); + assertEquals(result, { capture: true }); + }); + + await t.step("should return options with once when encoded is 2", () => { + const encoded = 2; + const result = decode(encoded); + assertEquals(result, { once: true }); + }); + + await t.step("should return options with passive when encoded is 4", () => { + const encoded = 4; + const result = decode(encoded); + assertEquals(result, { passive: true }); + }); + + await t.step("should return options with all flags when encoded is 7", () => { + const encoded = 7; + const result = decode(encoded); + assertEquals(result, { capture: true, once: true, passive: true }); + }); +}); diff --git a/browser/dom/_internal.ts b/browser/dom/_internal.ts new file mode 100644 index 0000000..66c6c95 --- /dev/null +++ b/browser/dom/_internal.ts @@ -0,0 +1,34 @@ +/** 等値比較用に`AddEventListenerOptions`をencodeする */ +export const encode = ( + options: AddEventListenerOptions | boolean | undefined, +): number => { + if (options === undefined) return 0; + if (typeof options === "boolean") return Number(options); + // 各フラグをビットにエンコードする + return ( + (options.capture ? 1 : 0) | + (options.once ? 2 : 0) | + (options.passive ? 4 : 0) + ); +}; +/** 等値比較用にencodeした`AddEventListenerOptions`をdecodeする + * + * - `capture`: `0b001` + * - `once`: `0b010` + * - `passive`: `0b100` + * - `0`: `undefined` + * + * @param encoded `AddEventListenerOptions`をencodeした値 + * @returns `AddEventListenerOptions`または`undefined` + */ +export const decode = ( + encoded: number, +): AddEventListenerOptions | undefined => { + if (encoded === 0) return; + const options: AddEventListenerOptions = {}; + if (encoded & 1) options.capture = true; + if (encoded & 2) options.once = true; + if (encoded & 4) options.passive = true; + + return options; +}; diff --git a/browser/dom/mod.ts b/browser/dom/mod.ts index fe57920..bcce7e8 100644 --- a/browser/dom/mod.ts +++ b/browser/dom/mod.ts @@ -14,3 +14,4 @@ export * from "./stores.ts"; export * from "./takeInternalLines.ts"; export * from "./pushPageTransition.ts"; export * from "./extractCodeFiles.ts"; +export * from "./textInputEventListener.ts" diff --git a/browser/dom/textInputEventListener.ts b/browser/dom/textInputEventListener.ts new file mode 100644 index 0000000..97cf536 --- /dev/null +++ b/browser/dom/textInputEventListener.ts @@ -0,0 +1,110 @@ +import { Scrapbox } from "../../deps/scrapbox.ts"; +import { textInput } from "./dom.ts"; +import { decode, encode } from "./_internal.ts"; +declare const scrapbox: Scrapbox; + +/** - first key: event name + * - second key: listener + * - value: encoded options + */ +const listenerMap = new Map< + keyof HTMLElementEventMap, + Map> +>(); +const onceListenerMap = new Map>(); + +/** `#text-input`に対してイベントリスナーを追加する + * + * `#text-input`はページレイアウトが変わると削除されるため、登録したイベントリスナーの記憶と再登録をこの関数で行っている + * + * @param name event name + * @param listener event listener + * @param options event listener options + * @returns + */ +export const addTextInputEventListener = ( + name: K, + listener: ( + this: HTMLTextAreaElement, + event: HTMLElementEventMap[K], + ) => unknown, + options?: boolean | AddEventListenerOptions, +): void => { + const argMap = listenerMap.get(name) ?? new Map>(); + const encodedOptions = argMap.get(listener as EventListener) ?? new Set(); + if (encodedOptions.has(encode(options))) return; + encodedOptions.add(encode(options)); + argMap.set(listener as EventListener, encodedOptions); + listenerMap.set(name, argMap); + if (typeof options === "object" && options?.once) { + const onceMap = onceListenerMap.get(listener as EventListener) ?? + new Map(); + const encoded = encode(options); + + /** 呼び出し時に、`listenerMap`からの登録も解除するwrapper listener */ + const onceListener = function ( + this: HTMLTextAreaElement, + event: HTMLElementEventMap[K], + ) { + removeTextInputEventListener(name, listener, options); + onceMap.delete(encoded); + return listener.call(this, event); + }; + onceMap.set(encoded, onceListener as EventListener); + onceListenerMap.set(listener as EventListener, onceMap); + + const textinput = textInput(); + if (!textinput) return; + textinput.addEventListener(name, onceListener, options); + } + const textinput = textInput(); + if (!textinput) return; + textinput.addEventListener(name, listener, options); +}; + +// re-register event listeners when the layout changes +scrapbox.on("layout:changed", () => { + const textinput = textInput(); + if (!textinput) return; + for (const [name, argMap] of listenerMap) { + for (const [listener, encodedOptions] of argMap) { + for (const encoded of encodedOptions) { + textinput.addEventListener( + name, + listener as EventListener, + decode(encoded), + ); + } + } + } +}); + +export const removeTextInputEventListener = < + K extends keyof HTMLElementEventMap, +>( + name: K, + listener: (event: HTMLElementEventMap[K]) => unknown, + options?: boolean | AddEventListenerOptions, +): void => { + const argMap = listenerMap.get(name); + if (!argMap) return; + const encodedOptions = argMap.get(listener as EventListener); + if (!encodedOptions) return; + const encoded = encode(options); + encodedOptions.delete(encoded); + if (typeof options === "object" && options?.once) { + const onceMap = onceListenerMap.get(listener as EventListener); + if (!onceMap) return; + const onceListener = onceMap.get(encoded); + if (!onceListener) return; + + const textinput = textInput(); + if (!textinput) return; + textinput.removeEventListener(name, onceListener, options); + onceMap.delete(encoded); + return; + } + const textinput = textInput(); + if (!textinput) return; + textinput.removeEventListener(name, listener, options); +}; From dcec5235470cc19e22092c098bd2cd0c47b96745 Mon Sep 17 00:00:00 2001 From: takker99 <37929109+takker99@users.noreply.github.com> Date: Fri, 24 May 2024 17:22:17 +0900 Subject: [PATCH 2/2] style(browser): deno fmt --- browser/dom/mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/browser/dom/mod.ts b/browser/dom/mod.ts index bcce7e8..6275900 100644 --- a/browser/dom/mod.ts +++ b/browser/dom/mod.ts @@ -14,4 +14,4 @@ export * from "./stores.ts"; export * from "./takeInternalLines.ts"; export * from "./pushPageTransition.ts"; export * from "./extractCodeFiles.ts"; -export * from "./textInputEventListener.ts" +export * from "./textInputEventListener.ts";