feat: extension feedback built in

This commit is contained in:
2026-07-26 15:58:03 +09:30
parent fe7127a83c
commit 318000009f
25 changed files with 1473 additions and 167 deletions
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { titleBarState } from "./titleBarState.svelte";
</script>
<div id="bsplus-title-root">
<span data-testid="page-title">{titleBarState.pageTitle}</span>
{#if titleBarState.showSearch}
<div class="search-trigger-wrapper">
<div class="search-trigger-anchor">
<div class="search-trigger">
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</span>
<p>Quick search...</p>
<span
class="search-trigger-hotkey"
style="margin-left: auto; display: flex; align-items: center; color: rgb(119, 119, 119); font-size: 12px;"
>{titleBarState.searchHotkeyLabel}</span>
</div>
<div class="search-progress-bar-wrapper">
<div class="search-progress-track">
<div class="search-progress-bar" style="width: 0%;"></div>
</div>
</div>
</div>
<div class="search-progress-text" aria-live="polite"></div>
</div>
{/if}
</div>
+8
View File
@@ -0,0 +1,8 @@
export {
prepareCustomTitleBarEarly,
mountCustomTitleBar,
unmountCustomTitleBar,
setCustomTitleBarText,
waitForCustomTitleBarReady,
} from "./mountCustomTitleBar";
export { titleBarState } from "./titleBarState.svelte";
@@ -0,0 +1,161 @@
import { mount, unmount } from "svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
import { waitForElm } from "@/seqta/utils/waitForElm";
import TitleBar from "./TitleBar.svelte";
import { titleBarState } from "./titleBarState.svelte";
const ROOT_ID = "bsplus-title-root";
const TITLE_CLASS = "bsplus-custom-title";
const PENDING_CLASS = "bsplus-custom-title-pending";
let app: ReturnType<typeof mount> | null = null;
let titleEl: HTMLElement | null = null;
let hostObserver: MutationObserver | null = null;
let earlyPrepareStarted = false;
let remountTimer: ReturnType<typeof setTimeout> | null = null;
function nativePageTitleEl(host: HTMLElement) {
return [...host.children].find(
(el) =>
el instanceof HTMLElement &&
el.id !== ROOT_ID &&
el.matches('span[data-testid="page-title"]'),
) as HTMLElement | undefined;
}
function syncPageTitle() {
if (!titleEl) return;
titleBarState.pageTitle = (nativePageTitleEl(titleEl)?.textContent ?? "").trim();
}
function needsSearchChip() {
const all = settingsState.getAll() as unknown as Record<string, unknown>;
const plugin = all["plugin.global-search.settings"] as
| { enabled?: boolean }
| undefined;
return plugin?.enabled === true || titleBarState.showSearch;
}
function isReady() {
const root = document.getElementById(ROOT_ID);
if (!root || !titleEl?.classList.contains(TITLE_CLASS)) return false;
if (!needsSearchChip()) return true;
return Boolean(root.querySelector(".search-trigger-wrapper"));
}
/** finishLoad waits here so the overlay stays until the title bar is ready. */
export async function waitForCustomTitleBarReady(timeoutMs = 10000) {
if (isSeqtaEngageExperience() || !settingsState.onoff) {
document.documentElement.classList.remove(PENDING_CLASS);
return;
}
await mountCustomTitleBar();
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (isReady()) break;
await new Promise((r) => setTimeout(r, 50));
}
document.documentElement.classList.remove(PENDING_CLASS);
}
function observeHost(host: HTMLElement) {
hostObserver?.disconnect();
syncPageTitle();
hostObserver = new MutationObserver(() => {
if (!document.getElementById(ROOT_ID)) {
if (remountTimer) clearTimeout(remountTimer);
remountTimer = setTimeout(() => {
remountTimer = null;
if (!settingsState.onoff || isSeqtaEngageExperience()) return;
app = null;
void mountCustomTitleBar();
}, 50);
return;
}
syncPageTitle();
});
hostObserver.observe(host, {
childList: true,
subtree: true,
characterData: true,
});
}
export function prepareCustomTitleBarEarly() {
if (isSeqtaEngageExperience() || !settingsState.onoff || earlyPrepareStarted) {
return;
}
earlyPrepareStarted = true;
document.documentElement.classList.add(PENDING_CLASS);
void mountCustomTitleBar();
}
export async function mountCustomTitleBar(): Promise<boolean> {
if (isSeqtaEngageExperience() || !settingsState.onoff) return false;
if (app && titleEl && document.getElementById(ROOT_ID)) {
observeHost(titleEl);
return true;
}
document.documentElement.classList.add(PENDING_CLASS);
let title: HTMLElement;
try {
title = (await waitForElm("#title", true, 50, 200)) as HTMLElement;
} catch {
return false;
}
titleEl = title;
title.classList.add(TITLE_CLASS);
syncPageTitle();
if (!document.getElementById(ROOT_ID)) {
if (app) {
try {
unmount(app);
} catch {
/* ignore */
}
app = null;
}
app = mount(TitleBar, { target: title });
}
observeHost(title);
return true;
}
export function unmountCustomTitleBar() {
hostObserver?.disconnect();
hostObserver = null;
if (remountTimer) clearTimeout(remountTimer);
remountTimer = null;
if (app) {
try {
unmount(app);
} catch {
/* ignore */
}
app = null;
}
document.getElementById(ROOT_ID)?.remove();
titleEl?.classList.remove(TITLE_CLASS);
titleEl = null;
titleBarState.pageTitle = "";
titleBarState.showSearch = false;
earlyPrepareStarted = false;
document.documentElement.classList.remove(PENDING_CLASS);
}
export function setCustomTitleBarText(text: string) {
titleBarState.pageTitle = text;
const native = titleEl && nativePageTitleEl(titleEl);
if (native) native.textContent = text;
}
@@ -0,0 +1,5 @@
export const titleBarState = $state({
pageTitle: "",
showSearch: false,
searchHotkeyLabel: "Ctrl+K",
});