Align settings controls with Obsidian components

This commit is contained in:
murashit 2026-06-28 07:46:32 +09:00
parent e548d70356
commit 34973dd7d7
3 changed files with 71 additions and 68 deletions

View file

@ -1,8 +1,7 @@
import type { TargetedEvent, TargetedKeyboardEvent, ComponentChild as UiNode } from "preact";
import { useEffect, useState } from "preact/hooks";
import type { ComponentChild as UiNode } from "preact";
import { DEFAULT_CODEX_PATH } from "../constants";
import { IconButton } from "../shared/ui/components.obsidian";
import { IconButton, ObsidianCommitTextInput, ObsidianDropdown, ObsidianToggle } from "../shared/ui/components.obsidian";
import type { SendShortcut } from "../shared/ui/keyboard";
import { ArchivedThreadSection } from "./archived-section";
import { HelperSettingsSection } from "./helper-section";
@ -14,6 +13,10 @@ const SEND_SHORTCUT_LABELS = {
enter: "Enter",
"mod-enter": "Cmd/Ctrl+Enter",
} as const;
const SEND_SHORTCUT_OPTIONS: { value: SendShortcut; label: string }[] = [
{ value: "enter", label: SEND_SHORTCUT_LABELS.enter },
{ value: "mod-enter", label: SEND_SHORTCUT_LABELS["mod-enter"] },
];
interface SettingsTabPanelState {
codexPath: string;
@ -75,10 +78,10 @@ function PanelPreferenceSections({ panel, actions }: { panel: SettingsTabPanelSt
<SettingsGroup className="codex-panel-settings__section codex-panel-settings__general-section">
<SettingsItems>
<SettingRow name="Codex executable" desc="Command used to start `codex app-server`; use an absolute path if needed.">
<CommitTextInput value={panel.codexPath} placeholder={DEFAULT_CODEX_PATH} onCommit={actions.setCodexPath} />
<ObsidianCommitTextInput value={panel.codexPath} placeholder={DEFAULT_CODEX_PATH} onCommit={actions.setCodexPath} />
</SettingRow>
<SettingRow name="Show chat toolbar" desc="Show the toolbar above the chat panel.">
<SettingsCheckbox checked={panel.showToolbar} onChange={actions.setShowToolbar} />
<ObsidianToggle checked={panel.showToolbar} onChange={actions.setShowToolbar} />
</SettingRow>
</SettingsItems>
</SettingsGroup>
@ -86,72 +89,22 @@ function PanelPreferenceSections({ panel, actions }: { panel: SettingsTabPanelSt
<SettingsHeading name="Composer" />
<SettingsItems>
<SettingRow name="Send shortcut" desc="Pick Enter or Cmd/Ctrl+Enter. Shift+Enter adds a newline when Enter sends.">
<select
<ObsidianDropdown
value={panel.sendShortcut}
onChange={(event) => {
actions.setSendShortcut(event.currentTarget.value === "mod-enter" ? "mod-enter" : "enter");
onChange={(value) => {
actions.setSendShortcut(value === "mod-enter" ? "mod-enter" : "enter");
}}
>
<option value="enter">{SEND_SHORTCUT_LABELS.enter}</option>
<option value="mod-enter">{SEND_SHORTCUT_LABELS["mod-enter"]}</option>
</select>
options={SEND_SHORTCUT_OPTIONS}
/>
</SettingRow>
<SettingRow
name="Scroll thread from composer line edges"
desc="Use Up/Ctrl+P and Down/Ctrl+N at composer line edges to scroll the thread."
>
<SettingsCheckbox checked={panel.scrollThreadFromComposerEdges} onChange={actions.setScrollThreadFromComposerEdges} />
<ObsidianToggle checked={panel.scrollThreadFromComposerEdges} onChange={actions.setScrollThreadFromComposerEdges} />
</SettingRow>
</SettingsItems>
</SettingsGroup>
</>
);
}
function CommitTextInput({
value,
placeholder,
onCommit,
}: {
value: string;
placeholder: string;
onCommit: (value: string) => void;
}): UiNode {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
const commit = (nextValue = draft): void => {
onCommit(nextValue);
};
return (
<input
type="text"
value={draft}
placeholder={placeholder}
onInput={(event) => {
setDraft(event.currentTarget.value);
}}
onBlur={(event) => {
commit(event.currentTarget.value);
}}
onKeyDown={(event: TargetedKeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter") return;
event.preventDefault();
commit(event.currentTarget.value);
}}
/>
);
}
function SettingsCheckbox({ checked, onChange }: { checked: boolean; onChange: (value: boolean) => void }): UiNode {
return (
<input
type="checkbox"
checked={checked}
onChange={(event: TargetedEvent<HTMLInputElement>) => {
onChange(event.currentTarget.checked);
}}
/>
);
}

View file

@ -14,6 +14,12 @@ export interface ObsidianDropdownOption {
label: string;
}
function useLatestRef<T>(value: T): { current: T } {
const ref = useRef(value);
ref.current = value;
return ref;
}
function ObsidianIcon({ icon, className }: ObsidianIconProps): UiNode {
const ref = useRef<HTMLSpanElement | null>(null);
useLayoutEffect(() => {
@ -115,6 +121,7 @@ export function ObsidianDropdown({
onChange: (value: string) => void;
}): UiNode {
const ref = useRef<HTMLSpanElement | null>(null);
const onChangeRef = useLatestRef(onChange);
useLayoutEffect(() => {
const container = ref.current;
if (!container) return;
@ -124,12 +131,12 @@ export function ObsidianDropdown({
dropdown.addOption(option.value, option.label);
}
dropdown.setValue(value).onChange((selected) => {
onChange(selected);
onChangeRef.current(selected);
});
return () => {
container.empty();
};
}, [onChange, options, value]);
}, [onChangeRef, options, value]);
return <span ref={ref} />;
}
@ -144,6 +151,7 @@ export function ObsidianTextInput({
onChange: (value: string) => void;
}): UiNode {
const ref = useRef<HTMLSpanElement | null>(null);
const onChangeRef = useLatestRef(onChange);
useLayoutEffect(() => {
const container = ref.current;
if (!container) return;
@ -153,30 +161,67 @@ export function ObsidianTextInput({
.setPlaceholder(placeholder)
.setValue(value)
.onChange((nextValue) => {
onChange(nextValue);
onChangeRef.current(nextValue);
});
return () => {
container.empty();
};
}, [onChange, placeholder, value]);
}, [onChangeRef, placeholder, value]);
return <span ref={ref} />;
}
export function ObsidianCommitTextInput({
value,
placeholder,
onCommit,
}: {
value: string;
placeholder: string;
onCommit: (value: string) => void;
}): UiNode {
const ref = useRef<HTMLSpanElement | null>(null);
const onCommitRef = useLatestRef(onCommit);
useLayoutEffect(() => {
const container = ref.current;
if (!container) return;
container.empty();
const text = new TextComponent(container);
text.setPlaceholder(placeholder).setValue(value);
const commit = () => {
onCommitRef.current(text.inputEl.value);
};
return disposeDomListeners(
listenDomEvent(text.inputEl, "blur", commit),
listenDomEvent(text.inputEl, "keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
commit();
}),
() => {
container.empty();
},
);
}, [onCommitRef, placeholder, value]);
return <span ref={ref} />;
}
export function ObsidianToggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }): UiNode {
const ref = useRef<HTMLSpanElement | null>(null);
const onChangeRef = useLatestRef(onChange);
useLayoutEffect(() => {
const container = ref.current;
if (!container) return;
container.empty();
const toggle = new ToggleComponent(container);
toggle.setValue(checked).onChange((nextValue) => {
onChange(nextValue);
onChangeRef.current(nextValue);
});
return () => {
container.empty();
};
}, [checked, onChange]);
}, [checked, onChangeRef]);
return <span ref={ref} />;
}

View file

@ -84,9 +84,12 @@ describe("settings tab", () => {
const tab = newSettingsTab({ saveSettings });
tab.display();
expect(inputForSetting(tab, "Codex executable")?.getAttribute("aria-label")).toBeNull();
const codexPath = inputForSetting(tab, "Codex executable");
expect(codexPath?.getAttribute("aria-label")).toBeNull();
expect(codexPath?.type).toBe("text");
const shortcut = selectForSetting(tab, "Send shortcut");
if (!shortcut) throw new Error("Missing send shortcut dropdown");
expect(shortcut.classList.contains("dropdown")).toBe(true);
shortcut.value = "mod-enter";
shortcut.dispatchEvent(new Event("change"));
@ -105,6 +108,7 @@ describe("settings tab", () => {
tab.display();
const toggle = inputForSetting(tab, "Show chat toolbar");
if (!toggle) throw new Error("Missing toolbar visibility toggle");
expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
toggle.checked = false;
toggle.dispatchEvent(new Event("change"));
@ -122,6 +126,7 @@ describe("settings tab", () => {
tab.display();
const toggle = inputForSetting(tab, "Scroll thread from composer line edges");
if (!toggle) throw new Error("Missing composer line edge scroll toggle");
expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
toggle.checked = true;
toggle.dispatchEvent(new Event("change"));