import { useCallback, useEffect, useRef } from 'react';
// Joplin's renderer emits `` elements for `- [ ]` /
// `- [x]` markdown syntax, with an inline onclick that calls
// `ipcProxySendToHost(...)` — that handler doesn't exist outside the note
// viewer iframe, so clicks are no-ops in our card context.
//
// This hook returns a callback ref. Attach it to the container that holds
// the rendered HTML (`
`).
// On every change to that container's children, the hook rewires checkboxes
// inside it: it strips the broken inline onclick, makes them enabled, and
// installs a click listener that flips the corresponding `[ ]` / `[x]` in
// the source markdown by index.
interface Options {
body: string;
onChange: (newBody: string)=> void;
}
// Matches a markdown task-list checkbox. The capture group is the inner
// character (' ' or 'x' / 'X'). Joplin's renderer only treats `- [ ]` /
// `- [x]` (dash-prefixed list items) as checkboxes — `*` and `+` markers and
// numbered lists are ignored — so we match exactly that subset, including
// Windows line endings.
const checkboxRegex = /(?<=(?:^|\r?\n)[ \t]*-[ \t]+)\[([ xX])\]/g;
// Exported for tests. Returns the body with the Nth `- [ ]` / `- [x]`
// flipped, or null if there's no Nth checkbox.
export const flipNthCheckbox = (body: string, index: number): string | null => {
let count = 0;
let result = '';
let lastIndex = 0;
let mutated = false;
const regex = new RegExp(checkboxRegex.source, 'g');
let match: RegExpExecArray | null;
while ((match = regex.exec(body)) !== null) {
if (count === index) {
const newChar = match[1] === ' ' ? 'x' : ' ';
result += `${body.slice(lastIndex, match.index)}[${newChar}]`;
lastIndex = match.index + match[0].length;
mutated = true;
break;
}
count++;
}
if (!mutated) return null;
return result + body.slice(lastIndex);
};
const useCheckboxToggle = ({ body, onChange }: Options) => {
const bodyRef = useRef(body);
bodyRef.current = body;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const observerRef = useRef(null);
// Track which checkbox elements have already been wired up. WeakSet so
// removed elements can be GC'd without leaking entries.
const wiredRef = useRef>(new WeakSet());
const wireUp = useCallback((root: HTMLElement) => {
const checkboxes = root.querySelectorAll('input[type=checkbox]');
for (const cb of Array.from(checkboxes)) {
if (wiredRef.current.has(cb)) continue;
wiredRef.current.add(cb);
cb.disabled = false;
cb.removeAttribute('disabled');
// Strip the renderer's inline onclick — it tries to call
// ipcProxySendToHost, which we don't expose in this context.
cb.removeAttribute('onclick');
(cb as HTMLInputElement & { onclick: unknown }).onclick = null;
cb.addEventListener('click', (e) => {
e.stopPropagation();
// Recompute the checkbox's current position at click time —
// the wire-time index goes stale after DOM insertions or
// removals (e.g. an external edit added/removed a list item
// before this one).
const current = Array.from(root.querySelectorAll('input[type=checkbox]'));
const currentIndex = current.indexOf(cb);
if (currentIndex < 0) return;
const next = flipNthCheckbox(bodyRef.current, currentIndex);
if (next !== null) onChangeRef.current(next);
});
cb.addEventListener('mousedown', (e) => e.stopPropagation());
}
}, []);
// Callback ref: fires whenever the underlying element changes (mount,
// unmount, replacement). Using a useCallback identity-stable ref means
// React only invokes it when the element actually changes.
const refCallback = useCallback((el: HTMLDivElement | null) => {
// Tear down previous observer.
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (!el) return;
wireUp(el);
const observer = new MutationObserver(() => wireUp(el));
observer.observe(el, { childList: true, subtree: true });
observerRef.current = observer;
}, [wireUp]);
// Clean up on unmount.
useEffect(() => {
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
};
}, []);
return refCallback;
};
export default useCheckboxToggle;