Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions browser/dom/_internal.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
34 changes: 34 additions & 0 deletions browser/dom/_internal.ts
Original file line number Diff line number Diff line change
@@ -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;
};
1 change: 1 addition & 0 deletions browser/dom/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from "./stores.ts";
export * from "./takeInternalLines.ts";
export * from "./pushPageTransition.ts";
export * from "./extractCodeFiles.ts";
export * from "./textInputEventListener.ts";
110 changes: 110 additions & 0 deletions browser/dom/textInputEventListener.ts
Original file line number Diff line number Diff line change
@@ -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<EventListener, Set<number>>
>();
const onceListenerMap = new Map<EventListener, Map<number, EventListener>>();

/** `#text-input`に対してイベントリスナーを追加する
*
* `#text-input`はページレイアウトが変わると削除されるため、登録したイベントリスナーの記憶と再登録をこの関数で行っている
*
* @param name event name
* @param listener event listener
* @param options event listener options
* @returns
*/
export const addTextInputEventListener = <K extends keyof HTMLElementEventMap>(
name: K,
listener: (
this: HTMLTextAreaElement,
event: HTMLElementEventMap[K],
) => unknown,
options?: boolean | AddEventListenerOptions,
): void => {
const argMap = listenerMap.get(name) ?? new Map<EventListener, Set<number>>();
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<number, EventListener>();
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<K>(name, onceListener, options);
}
const textinput = textInput();
if (!textinput) return;
textinput.addEventListener<K>(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);
};