mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add React infrastructure
This commit is contained in:
parent
df2976e984
commit
4dc1c2f86a
9 changed files with 217 additions and 3 deletions
|
|
@ -10,6 +10,7 @@ const context = await esbuild.context({
|
|||
format: "cjs",
|
||||
platform: "node",
|
||||
target: "es2022",
|
||||
jsx: "automatic",
|
||||
outfile: "main.js",
|
||||
sourcemap: production ? false : "inline",
|
||||
minify: production,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import eslintConfigPrettier from "eslint-config-prettier/flat";
|
|||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const typeScriptFiles = ["src/**/*.ts", "tests/**/*.ts"];
|
||||
const typeScriptFiles = ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"];
|
||||
const nodeJavaScriptFiles = ["*.mjs", "scripts/**/*.mjs"];
|
||||
const typeScriptConfigFiles = ["*.config.ts"];
|
||||
const lintedTypeScriptFiles = [...typeScriptFiles, ...typeScriptConfigFiles];
|
||||
|
|
@ -66,7 +66,7 @@ export default defineConfig([
|
|||
},
|
||||
},
|
||||
{
|
||||
files: ["tests/**/*.ts"],
|
||||
files: ["tests/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
|
|
|
|||
60
package-lock.json
generated
60
package-lock.json
generated
|
|
@ -8,9 +8,15 @@
|
|||
"name": "codex-panel",
|
||||
"version": "2.2.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
|
@ -1707,6 +1713,26 @@
|
|||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
|
||||
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tern": {
|
||||
"version": "0.23.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
|
||||
|
|
@ -2630,6 +2656,13 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
|
|
@ -6417,6 +6450,27 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
|
@ -6710,6 +6764,12 @@
|
|||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@
|
|||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
|
@ -47,5 +49,9 @@
|
|||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vitest": "^4.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
src/shared/ui/react-components.tsx
Normal file
39
src/shared/ui/react-components.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { setIcon } from "obsidian";
|
||||
import { useLayoutEffect, useRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
export interface ObsidianIconProps {
|
||||
icon: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ObsidianIcon({ icon, className }: ObsidianIconProps): ReactNode {
|
||||
const ref = useRef<HTMLSpanElement | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
element.replaceChildren();
|
||||
setIcon(element, icon);
|
||||
}, [icon]);
|
||||
return <span ref={ref} className={className} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function IconButton({ icon, label, className, children, ...props }: IconButtonProps): ReactNode {
|
||||
const ref = useRef<HTMLButtonElement | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const button = ref.current;
|
||||
if (!button || children) return;
|
||||
button.replaceChildren();
|
||||
setIcon(button, icon);
|
||||
}, [children, icon]);
|
||||
return (
|
||||
<button {...props} ref={ref} className={className} aria-label={label} type={props.type ?? "button"}>
|
||||
{children ? <ObsidianIcon icon={icon} /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
55
src/shared/ui/react-root.tsx
Normal file
55
src/shared/ui/react-root.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
|
||||
const roots = new WeakMap<HTMLElement, Root>();
|
||||
const guardedContainers = new WeakSet<HTMLElement>();
|
||||
const internalMutationContainers = new WeakSet<HTMLElement>();
|
||||
|
||||
export function renderReactRoot(container: HTMLElement, node: ReactNode): void {
|
||||
const root = rootForContainer(container);
|
||||
internalMutationContainers.add(container);
|
||||
try {
|
||||
flushSync(() => {
|
||||
root.render(node);
|
||||
});
|
||||
} finally {
|
||||
internalMutationContainers.delete(container);
|
||||
}
|
||||
}
|
||||
|
||||
export function unmountReactRoot(container: HTMLElement | null): void {
|
||||
if (!container) return;
|
||||
const root = roots.get(container);
|
||||
if (!root) return;
|
||||
internalMutationContainers.add(container);
|
||||
try {
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
} finally {
|
||||
internalMutationContainers.delete(container);
|
||||
roots.delete(container);
|
||||
}
|
||||
}
|
||||
|
||||
function rootForContainer(container: HTMLElement): Root {
|
||||
const existing = roots.get(container);
|
||||
if (existing) return existing;
|
||||
guardExternalEmpty(container);
|
||||
const root = createRoot(container);
|
||||
roots.set(container, root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function guardExternalEmpty(container: HTMLElement): void {
|
||||
if (guardedContainers.has(container)) return;
|
||||
guardedContainers.add(container);
|
||||
const replaceChildren = container.replaceChildren.bind(container);
|
||||
container.replaceChildren = (...nodes) => {
|
||||
if (!internalMutationContainers.has(container)) {
|
||||
unmountReactRoot(container);
|
||||
}
|
||||
replaceChildren(...nodes);
|
||||
};
|
||||
}
|
||||
28
tests/features/chat/ui/react-root.test.tsx
Normal file
28
tests/features/chat/ui/react-root.test.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderReactRoot, unmountReactRoot } from "../../../../src/shared/ui/react-root";
|
||||
|
||||
describe("React root adapter", () => {
|
||||
it("reuses roots that render no host children", () => {
|
||||
const parent = document.createElement("div");
|
||||
|
||||
renderReactRoot(parent, null);
|
||||
renderReactRoot(parent, <button type="button">Ready</button>);
|
||||
|
||||
expect(parent.querySelector("button")?.textContent).toBe("Ready");
|
||||
unmountReactRoot(parent);
|
||||
});
|
||||
|
||||
it("recovers when a non-empty host is emptied imperatively", () => {
|
||||
const parent = document.createElement("div");
|
||||
|
||||
renderReactRoot(parent, <button type="button">Before</button>);
|
||||
parent.replaceChildren();
|
||||
renderReactRoot(parent, <button type="button">After</button>);
|
||||
|
||||
expect(parent.querySelector("button")?.textContent).toBe("After");
|
||||
unmountReactRoot(parent);
|
||||
});
|
||||
});
|
||||
24
tests/features/chat/ui/react-test-helpers.tsx
Normal file
24
tests/features/chat/ui/react-test-helpers.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
export interface ReactTestRoot {
|
||||
render(node: ReactNode): void;
|
||||
unmount(): void;
|
||||
}
|
||||
|
||||
export function createReactTestRoot(container: HTMLElement): ReactTestRoot {
|
||||
const root = createRoot(container);
|
||||
return {
|
||||
render(node) {
|
||||
act(() => {
|
||||
root.render(node);
|
||||
});
|
||||
},
|
||||
unmount() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -17,8 +17,9 @@
|
|||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node", "vitest"],
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"]
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx", "vitest.config.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue