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
5,815 changes: 4,467 additions & 1,348 deletions webapp/_webapp/package-lock.json

Large diffs are not rendered by default.

17 changes: 14 additions & 3 deletions webapp/_webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,21 @@
"@buf/googleapis_googleapis.bufbuild_es": "^2.2.3-20250211200939-546238c53f73.1",
"@bufbuild/protobuf": "^2.5.1",
"@capacitor-community/apple-sign-in": "^7.0.1",
"@grafana/faro-web-sdk": "^2.0.2",
"@grafana/faro-web-tracing": "^2.0.2",
"@heroui/react": "^2.7.9",
"@iconify/react": "^6.0.0",
"@lukemorales/query-key-factory": "^1.3.4",
"@opentelemetry/exporter-logs-otlp-http": "^0.219.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.219.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.219.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
"@opentelemetry/instrumentation": "^0.219.0",
"@opentelemetry/instrumentation-document-load": "^0.64.0",
"@opentelemetry/instrumentation-fetch": "^0.219.0",
"@opentelemetry/resources": "^2.8.0",
"@opentelemetry/sdk-logs": "^0.219.0",
"@opentelemetry/sdk-metrics": "^2.8.0",
"@opentelemetry/sdk-trace-web": "^2.8.0",
"@opentelemetry/semantic-conventions": "^1.41.1",
"@r2wc/react-to-web-component": "^2.1.0",
"@streamdown/cjk": "^1.0.1",
"@streamdown/code": "^1.0.1",
Expand All @@ -56,13 +66,13 @@
"semver": "^7.7.2",
"streamdown": "^2.1.0",
"uuid": "^11.1.0",
"web-vitals": "^5.3.0",
"zustand": "^5.0.5"
},
"devDependencies": {
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.37.1",
"@eslint/js": "^9.28.0",
"@grafana/faro-rollup-plugin": "^0.7.0",
"@types/bun": "^1.3.5",
"@types/chrome": "^0.0.326",
"@types/codemirror": "^5.60.16",
Expand All @@ -78,6 +88,7 @@
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"nodemon": "^3.1.10",
"postcss-prefix-selector": "^2.1.1",
"prettier": "3.5.3",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.3",
Expand Down
32 changes: 32 additions & 0 deletions webapp/_webapp/postcss.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,38 @@
// ponytail: scope ALL css under .pd-scope ONLY for the content-script build
// (VITE_CONFIG=default) so preflight/heroui don't leak into Overleaf. The
// .pd-scope class sits on every container we mount into (#paper-debugger-root
// and #pd-embed-sidebar). Settings/popup are standalone pages and stay unscoped.
const SCOPE = ".pd-scope";
const scoped = process.env.VITE_CONFIG === "default";

export default {
plugins: {
tailwindcss: {},
...(scoped && {
"postcss-prefix-selector": {
prefix: SCOPE,
transform(prefix, selector, prefixedSelector) {
// Root-level globals apply to the scope container itself.
if (selector === "html" || selector === "body" || selector === ":root") return prefix;
// box-sizing/* reset: cover the scope element and everything inside it.
if (selector === "*") return `${prefix}, ${prefix} *`;
// Theme toggles + :root variants live ON the scope element → compound, not descendant.
if (/^\.(dark|light)\b/.test(selector)) return prefix + selector;
if (selector.startsWith(":root")) return prefix + selector.slice(":root".length);
if (selector.startsWith("[data-theme")) return prefix + selector;
// These elements ARE scope roots (carry .pd-scope on themselves) → compound,
// so rules targeting the element itself keep matching after scoping.
if (
selector.startsWith("#pd-embed-sidebar") ||
selector.startsWith("#paper-debugger-root") ||
selector.startsWith(".pd-rnd")
) {
return prefix + selector;
}
return prefixedSelector;
},
},
}),
autoprefixer: {},
},
};
2 changes: 1 addition & 1 deletion webapp/_webapp/src/components/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { memo } from "react";
import { Streamdown } from "streamdown";
import { code } from "@streamdown/code";
import { code } from "../libs/code-plugin";
import { mermaid } from "@streamdown/mermaid";
import { math } from "@streamdown/math";
import { cjk } from "@streamdown/cjk";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cn } from "@heroui/react";
import { useEffect, useState, useRef } from "react";
import { Streamdown } from "streamdown";
import { code } from "@streamdown/code";
import { code } from "../../../libs/code-plugin";
import { mermaid } from "@streamdown/mermaid";
import { math } from "@streamdown/math";
import { cjk } from "@streamdown/cjk";
Expand Down
2 changes: 1 addition & 1 deletion webapp/_webapp/src/components/pd-app-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export function PdAppContainer({
...props
}: React.HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
return (
<div className={cn("pd-app-container", className)} {...props}>
<div className={cn("pd-app-container", className)} id="pd-container" {...props}>
{children}
</div>
);
Expand Down
49 changes: 29 additions & 20 deletions webapp/_webapp/src/components/top-menu-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,33 +50,42 @@ export const TopMenuButton = () => {
id="paper-debugger-button"
onContextMenu={(event) => handleContextMenu(event)}
>
{/* Native Overleaf button stays OUTSIDE .pd-scope so Overleaf's own color
rules win (our preflight would otherwise reset its color to black).
Layout utilities are inlined; inner content is scoped for our utilities. */}
<button
className="btn btn-full-height flex gap-1 items-center justify-center ide-redesign-toolbar-dropdown-toggle-subdued ide-redesign-toolbar-button-subdued menu-bar-toggle"
className="btn btn-full-height ide-redesign-toolbar-dropdown-toggle-subdued ide-redesign-toolbar-button-subdued menu-bar-toggle"
style={{ display: "flex", gap: "0.25rem", alignItems: "center", justifyContent: "center" }}
onClick={() => setIsOpen(!isOpen)}
>
<Logo className="bg-transparent p-0 m-0 flex items-center justify-center w-6 h-6 align-middle" />
<p className={`text-exo-2 toolbar-label ${settings?.fullWidthPaperDebuggerButton ? "" : "hidden"}`}>
<span className="font-light">Paper</span>
<span className="font-bold">Debugger</span>
<span className="text-xs text-white bg-gray-700 rounded-md px-2 py-1 ms-2">
{getBrowser() === Browser.Chrome ? "⌘ + L" : "⌃ + L"}
</span>
</p>
{/* Use Overleaf's button color var so the logo (fill=currentColor) and label track the toolbar theme. */}
<span className="pd-scope" style={{ display: "contents", color: "var(--bs-btn-color)" }}>
<Logo className="bg-transparent p-0 m-0 flex items-center justify-center w-6 h-6 align-middle" />
<p className={`text-exo-2 toolbar-label ${settings?.fullWidthPaperDebuggerButton ? "" : "hidden"}`}>
<span className="font-light">Paper</span>
<span className="font-bold">Debugger</span>
<span className="text-xs text-white bg-gray-700 rounded-md px-2 py-1 ms-2">
{getBrowser() === Browser.Chrome ? "⌘ + L" : "⌃ + L"}
</span>
</p>
</span>
</button>
{/* Position reset menu */}
<div
className={`pd-context-menu noselect ${contextMenuVisible ? "show" : ""}`}
style={{
zIndex: 998,
}}
>
<div className="pd-context-menu-item-group">
<p className="text-xs text-gray-400 px-2">If you can't find the PaperDebugger window, try to:</p>
<div className="pd-context-menu-item noselect" onClick={handleResetPosition}>
<p>Reset Position</p>
<span className="pd-scope" style={{ display: "contents" }}>
<div
className={`pd-context-menu noselect ${contextMenuVisible ? "show" : ""}`}
style={{
zIndex: 998,
}}
>
<div className="pd-context-menu-item-group">
<p className="text-xs text-gray-400 px-2">If you can't find the PaperDebugger window, try to:</p>
<div className="pd-context-menu-item noselect" onClick={handleResetPosition}>
<p>Reset Position</p>
</div>
</div>
</div>
</div>
</span>
</div>
);
};
2 changes: 1 addition & 1 deletion webapp/_webapp/src/devtool/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
></div>

<!-- Paper Debugger Root -->
<div id="root-paper-debugger" class="hidden"></div>
<div id="paper-debugger-root" class="hidden"></div>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion webapp/_webapp/src/devtool/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AdapterProvider, getOverleafAdapter } from "../adapters";
const devTool = createRoot(document.getElementById("root-devtools")!);
devTool.render(<App />);

const paperDebugger = createRoot(document.getElementById("root-paper-debugger")!);
const paperDebugger = createRoot(document.getElementById("paper-debugger-root")!);
const adapter = getOverleafAdapter();
paperDebugger.render(
<Providers>
Expand Down
31 changes: 12 additions & 19 deletions webapp/_webapp/src/hooks/useThemeSync.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { useEffect } from "react";
import { useSettingStore } from "../stores/setting-store";

const THEME_ROOT_ID = "paper-debugger-root";

function getThemeRoot(): HTMLElement {
return document.getElementById(THEME_ROOT_ID) ?? document.documentElement;
}
// Every container that carries .pd-scope; the .dark class must sit on these so
// scoped heroui rules (.pd-scope.dark ...) resolve. Falls back to <html> on
// standalone pages (settings/popup) where no scope root exists.
const SCOPE_ROOT_IDS = ["paper-debugger-root", "pd-portal", "pd-embed-sidebar"];

function applyThemeToElement(el: HTMLElement, isDark: boolean): void {
if (isDark) {
Expand All @@ -15,37 +14,31 @@ function applyThemeToElement(el: HTMLElement, isDark: boolean): void {
}
}

/**
* Apply theme to all elements that may contain our UI.
* In Overleaf embed mode, the sidebar is rendered via portal into #pd-embed-sidebar
* (inside .ide-redesign-body), which is outside #paper-debugger-root. So we must
* also set the theme on documentElement so that portal content gets dark mode.
*/
function applyTheme(root: HTMLElement, isDark: boolean): void {
applyThemeToElement(root, isDark);
if (root.id === THEME_ROOT_ID && root !== document.documentElement) {
function applyTheme(isDark: boolean): void {
const roots = SCOPE_ROOT_IDS.map((id) => document.getElementById(id)).filter((el): el is HTMLElement => !!el);
if (roots.length === 0) {
applyThemeToElement(document.documentElement, isDark);
return;
}
for (const root of roots) applyThemeToElement(root, isDark);
}

export function useThemeSync(): void {
const themeMode = useSettingStore((s) => s.themeMode);

useEffect(() => {
const root = getThemeRoot();

if (themeMode === "light") {
applyTheme(root, false);
applyTheme(false);
return;
}
if (themeMode === "dark") {
applyTheme(root, true);
applyTheme(true);
return;
}

// themeMode === "auto": follow system
const media = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => applyTheme(root, media.matches);
const update = () => applyTheme(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
Expand Down
5 changes: 2 additions & 3 deletions webapp/_webapp/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@
--pd-default-bg: #fafafa;
}

/* Dark mode variables (applied when .dark is on theme root) */
#paper-debugger-root.dark,
html.dark {
/* Dark mode variables (applied when .dark is on the scope root) */
.dark {
--pd-border-color: oklch(35% 0 0);
--pd-border-color-error: var(--color-red-400);
--pd-default-bg: #18181b;
Expand Down
100 changes: 100 additions & 0 deletions webapp/_webapp/src/libs/code-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { createHighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import type { BundledLanguage, ThemeInput } from "shiki";
import type { CodeHighlighterPlugin } from "streamdown";

import langBash from "@shikijs/langs/bash";
import langC from "@shikijs/langs/c";
import langCpp from "@shikijs/langs/cpp";
import langGo from "@shikijs/langs/go";
import langJava from "@shikijs/langs/java";
import langJs from "@shikijs/langs/javascript";
import langLatex from "@shikijs/langs/latex";
import langMatlab from "@shikijs/langs/matlab";
import langPython from "@shikijs/langs/python";
import langR from "@shikijs/langs/r";
import langRust from "@shikijs/langs/rust";
import langSql from "@shikijs/langs/sql";
import langTs from "@shikijs/langs/typescript";

import themeLight from "@shikijs/themes/github-light";
import themeDark from "@shikijs/themes/ayu-dark";

const LANGS = [langBash, langC, langCpp, langGo, langJava, langJs, langLatex, langMatlab, langPython, langR, langRust, langSql, langTs];
const SUPPORTED = new Set(["bash", "sh", "c", "cpp", "go", "java", "javascript", "js", "latex", "tex", "matlab", "python", "r", "rust", "sql", "typescript", "ts"]);

type Themes = [ThemeInput, ThemeInput];
type Callback = (tokens: unknown) => void;

const pending = new Map<string, Set<Callback>>();
const cache = new Map<string, unknown>();

const highlighterPromise = createHighlighterCore({
themes: [themeLight, themeDark],
langs: LANGS,
engine: createJavaScriptRegexEngine({ forgiving: true }),
});

function cacheKey(code: string, lang: string, themes: Themes) {
const prefix = code.slice(0, 100);
const suffix = code.length > 100 ? code.slice(-100) : "";
return `${lang}:${themes[0]}:${themes[1]}:${code.length}:${prefix}:${suffix}`;
}

function themeName(t: unknown): string {
if (typeof t === "string") return t;
if (t && typeof t === "object" && "name" in t) return (t as { name: string }).name;
return "custom";
}

// ponytail: cast to satisfy cross-package ThemeInput conflicts; runtime shape matches CodeHighlighterPlugin
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const _code = {
name: "shiki",
type: "code-highlighter",
supportsLanguage(lang: string) {
return SUPPORTED.has(lang.trim().toLowerCase());
},
getSupportedLanguages() {
return Array.from(SUPPORTED) as BundledLanguage[];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getThemes(): any {
return ["github-light", "ayu-dark"];
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
highlight({ code: src, language, themes }: { code: string; language: string; themes: any }, cb?: Callback) {
const lang = SUPPORTED.has(language.trim().toLowerCase()) ? language.trim().toLowerCase() : "text";
const names: [string, string] = [themeName(themes[0]), themeName(themes[1])];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const key = cacheKey(src, lang, names as any);

if (cache.has(key)) return cache.get(key);

if (cb) {
if (!pending.has(key)) pending.set(key, new Set<Callback>());
pending.get(key)!.add(cb);
}

highlighterPromise.then((h) => {
const tokens = h.codeToTokens(src, {
lang: h.getLoadedLanguages().includes(lang) ? lang : "text",
themes: { light: names[0] as string, dark: names[1] as string },
});
cache.set(key, tokens);
const waiters = pending.get(key);
if (waiters) {
for (const fn of waiters) fn(tokens);
pending.delete(key);
}
}).catch((e) => {
console.error("[code-plugin] highlight failed:", e);
pending.delete(key);
});

return null;
},
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const code = _code as any as CodeHighlighterPlugin;
Loading
Loading