diff --git a/apps/docs/package.json b/apps/docs/package.json index 3b681ae9..4d8b44cf 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -47,6 +47,7 @@ "lz-string": "catalog:", "monaco-editor": "catalog:", "next-themes": "catalog:", + "prettier": "catalog:", "react": "catalog:", "react-aria-components": "catalog:", "react-dom": "catalog:", diff --git a/apps/docs/src/components/playground/editor.tsx b/apps/docs/src/components/playground/editor.tsx index 17d6c8de..58adae09 100644 --- a/apps/docs/src/components/playground/editor.tsx +++ b/apps/docs/src/components/playground/editor.tsx @@ -1,6 +1,14 @@ +import { Button } from '@luke-ui/react/button'; import Editor from '@monaco-editor/react'; -import { useEffect, useSyncExternalStore } from 'react'; +import type { OnMount } from '@monaco-editor/react'; +import type * as Monaco from 'monaco-editor'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { loadPlaygroundTypes, monacoThemes } from '../../lib/monaco-setup'; +import { + registerFormatDocumentKeybinding, + registerPlaygroundFormatter, + runFormatDocument, +} from '../../lib/playground-format'; import { EditorSkeleton } from './editor-skeleton'; type PlaygroundEditorProps = { @@ -15,37 +23,68 @@ export default function PlaygroundEditor({ showLoadingPill, }: PlaygroundEditorProps) { const theme = useSiteTheme(); + const editorRef = useRef(null); + const [isFormatting, setIsFormatting] = useState(false); useEffect(() => { void loadPlaygroundTypes(); }, []); + const handleMount: OnMount = (editor, monaco) => { + editorRef.current = editor; + registerPlaygroundFormatter(monaco); + registerFormatDocumentKeybinding(editor, monaco); + }; + + const handleFormat = async () => { + if (isFormatting) return; + setIsFormatting(true); + try { + await runFormatDocument(editorRef.current); + } finally { + setIsFormatting(false); + } + }; + return ( - } - onChange={(value) => onChange(value ?? '')} - options={{ - automaticLayout: true, - fixedOverflowWidgets: true, - folding: false, - // Font and layout metrics are mirrored by EditorSkeleton; keep them in sync. - fontFamily: - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', - fontSize: 13, - lineHeight: 20, - lineNumbersMinChars: 3, - minimap: { enabled: false }, - padding: { top: 12 }, - scrollBeyondLastLine: false, - tabSize: 2, - }} - path="file:///playground/index.tsx" - theme={theme} - /> +
+
+ +
+
+ } + onChange={(value) => onChange(value ?? '')} + onMount={handleMount} + options={{ + automaticLayout: true, + fixedOverflowWidgets: true, + folding: false, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 13, + lineHeight: 20, + lineNumbersMinChars: 3, + minimap: { enabled: false }, + padding: { top: 12 }, + scrollBeyondLastLine: false, + tabSize: 2, + }} + path="file:///playground/index.tsx" + theme={theme} + /> +
+
); } diff --git a/apps/docs/src/components/playground/playground-format.browser.test.tsx b/apps/docs/src/components/playground/playground-format.browser.test.tsx new file mode 100644 index 00000000..00ed71a8 --- /dev/null +++ b/apps/docs/src/components/playground/playground-format.browser.test.tsx @@ -0,0 +1,111 @@ +import '../../styles/app.css'; +import { act } from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { page, userEvent } from 'vite-plus/test/context'; +import { decodeCodeHash, encodeCodeHash } from '../../lib/playground-hash'; + +const PlaygroundEditor = (await import('./editor.js')).default; + +const badlyFormatted = 'const playgroundFormatTest=(x)=>x'; +const formatted = 'const playgroundFormatTest = (x) => x;'; + +let container: HTMLElement | undefined; +let root: Root | undefined; + +afterEach(() => { + if (root) act(() => root?.unmount()); + container?.remove(); + container = undefined; +}); + +test('monaco fills the editor pane and format updates source through onChange', async () => { + const hashRef = { current: window.location.hash }; + renderPlayground(badlyFormatted, (code) => { + hashRef.current = `#${encodeCodeHash(code)}`; + history.replaceState(null, '', hashRef.current); + }); + + const formatButton = page.getByRole('button', { name: 'Format' }); + await expect.element(formatButton).toBeVisible(); + + const monacoEditor = () => document.querySelector('.monaco-editor'); + await expect + .poll(() => monacoEditor()?.getBoundingClientRect().height ?? 0, { timeout: 30_000 }) + .toBeGreaterThan(100); + + const editorPane = () => document.querySelector('.monaco-editor')?.parentElement; + await expect + .poll( + () => { + const editorHeight = monacoEditor()?.getBoundingClientRect().height ?? 0; + const paneHeight = editorPane()?.getBoundingClientRect().height ?? 0; + return paneHeight > 0 ? editorHeight / paneHeight : 0; + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0.8); + + await expect + .poll(() => document.querySelector('.view-lines')?.textContent ?? '', { timeout: 30_000 }) + .toContain('playgroundFormatTest'); + + await userEvent.click(formatButton); + + const viewText = () => + (document.querySelector('.view-lines')?.textContent ?? '').replace(/\u00a0/g, ' '); + await expect.poll(() => viewText(), { timeout: 10_000 }).toContain(formatted); + await expect + .poll(() => decodeCodeHash(hashRef.current) ?? '', { timeout: 10_000 }) + .toContain(formatted); + + await userEvent.click(monacoEditor()!); + await userEvent.keyboard('{Control>}z{/Control}'); + await expect.poll(() => viewText(), { timeout: 10_000 }).toContain('playgroundFormatTest=(x)=>x'); +}, 60_000); + +test('Ctrl+S formats through the Monaco provider and onChange', async () => { + const onChangeCalls: Array = []; + renderPlayground(badlyFormatted, (code) => onChangeCalls.push(code)); + + const monacoEditor = () => document.querySelector('.monaco-editor'); + await expect + .poll(() => monacoEditor()?.getBoundingClientRect().height ?? 0, { timeout: 30_000 }) + .toBeGreaterThan(100); + + const viewText = () => + (document.querySelector('.view-lines')?.textContent ?? '').replace(/\u00a0/g, ' '); + + await userEvent.click(monacoEditor()!); + await userEvent.keyboard('{Control>}s{/Control}'); + + await expect.poll(() => viewText(), { timeout: 10_000 }).toContain(formatted); + await expect + .poll(() => onChangeCalls.some((code) => code.includes(formatted)), { timeout: 10_000 }) + .toBe(true); +}, 60_000); + +function renderPlayground(defaultValue: string, onChange: (code: string) => void) { + container = document.body.appendChild(document.createElement('div')); + container.className = 'h-[480px] min-h-0 overflow-hidden'; + root = createRoot(container); + + act(() => { + root?.render(); + }); +} + +function Harness({ + defaultValue, + onChange, +}: { + defaultValue: string; + onChange: (code: string) => void; +}) { + return ( +
+ +
+ ); +} diff --git a/apps/docs/src/lib/playground-format.test.ts b/apps/docs/src/lib/playground-format.test.ts new file mode 100644 index 00000000..6bfc7a03 --- /dev/null +++ b/apps/docs/src/lib/playground-format.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from 'vite-plus/test'; +import { + createPrettierLoader, + documentFormattingEdits, + formatPlaygroundSource, +} from './playground-format.js'; + +test('formats badly formatted valid TSX', async () => { + const source = 'const foo=()=>{return