diff options
Diffstat (limited to 'source/library/GraphiQL.svelte')
| -rw-r--r-- | source/library/GraphiQL.svelte | 307 |
1 files changed, 263 insertions, 44 deletions
diff --git a/source/library/GraphiQL.svelte b/source/library/GraphiQL.svelte index 329dd6f..4250fd3 100644 --- a/source/library/GraphiQL.svelte +++ b/source/library/GraphiQL.svelte @@ -10,14 +10,17 @@ import HeadersEditor from "./components/HeadersEditor.svelte"; import HistoryPanel from "./components/HistoryPanel.svelte"; import ResultViewer from "./components/ResultViewer.svelte"; + import Splitter from "./components/Splitter.svelte"; import TabBar from "./components/TabBar.svelte"; import Toolbar from "./components/Toolbar.svelte"; import type { Extension } from "@codemirror/state"; import type { Fetcher } from "./fetcher/types.ts"; import { HistoryStore } from "./state/history.svelte.ts"; + import { matchShortcut } from "./state/keyboard.ts"; import { SchemaStore } from "./state/schema.svelte.ts"; import { SessionStore } from "./state/session.svelte.ts"; import type { SubscriptionMode, Tab } from "./state/session.svelte.ts"; + import { validateSessionExport } from "./state/session-io.ts"; import { createLocalStorage, createMemoryStorage } from "./state/storage.ts"; import type { Storage } from "./state/storage.ts"; @@ -59,10 +62,28 @@ session.updateQuery(session.active.id, initialQuery); let bottomPane = $state<"variables" | "headers">("variables"); + let bottomHeight = $state(resolvedStorage.get<number>("layout.bottomHeight") ?? 35); + let centerEl = $state<HTMLDivElement | null>(null); let docsOpen = $state(resolvedStorage.get<boolean>("docExplorer") ?? false); + let docsWidth = $state(resolvedStorage.get<number>("layout.docsWidth") ?? 320); + let historyNotice = $state<string | null>(null); let historyOpen = $state(resolvedStorage.get<boolean>("historyPanel") ?? false); + let historyWidth = $state(resolvedStorage.get<number>("layout.historyWidth") ?? 260); + let leftEl = $state<HTMLDivElement | null>(null); + let leftWidth = $state(resolvedStorage.get<number>("layout.leftWidth") ?? 50); let running = $state(false); + let dragStartHistoryWidth = 0; + let dragStartDocsWidth = 0; + let dragStartLeftWidth = 0; + let dragStartLeftWidthPx = 0; + let dragStartBottomHeight = 0; + let dragStartBottomHeightPx = 0; + + function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); + } + $effect(() => { void session.tabs; void session.activeId; @@ -92,6 +113,22 @@ resolvedStorage.set("historyPanel", historyOpen); }); + $effect(() => { + resolvedStorage.set("layout.bottomHeight", bottomHeight); + }); + + $effect(() => { + resolvedStorage.set("layout.docsWidth", docsWidth); + }); + + $effect(() => { + resolvedStorage.set("layout.historyWidth", historyWidth); + }); + + $effect(() => { + resolvedStorage.set("layout.leftWidth", leftWidth); + }); + onMount(() => { schema.introspect(fetcher); }); @@ -149,6 +186,61 @@ session.overwriteActive(seed); } + function onExportSession() { + if (typeof globalThis.URL === "undefined") + return; + + if (typeof globalThis.document === "undefined") + return; + + const data = session.exportAll(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = globalThis.document.createElement("a"); + a.download = `graphiql-session-${data.exportedAt}.json`; + a.href = url; + a.click(); + URL.revokeObjectURL(url); + } + + async function onImportSession(file: File) { + let text: string; + + try { + text = await file.text(); + } catch (err) { + historyNotice = `Import failed: ${String(err)}`; + return; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(text); + } catch (err) { + historyNotice = `Import failed: invalid JSON (${String(err)})`; + return; + } + + const valid = validateSessionExport(parsed); + + if ("error" in valid) { + historyNotice = `Import failed: ${valid.error}`; + return; + } + + const result = session.importTabs(valid, { mode: "append" }); + const parts = [`Imported ${result.added} tab${result.added === 1 ? "" : "s"}`]; + + if (result.skipped > 0) + parts.push(`skipped ${result.skipped}`); + + if (result.errors.length > 0) + parts.push(`${result.errors.length} error${result.errors.length === 1 ? "" : "s"}`); + + historyNotice = parts.join(", "); + } + function onQueryChange(value: string) { if (session.active) session.updateQuery(session.active.id, value); @@ -165,17 +257,96 @@ } function onKeydown(event: KeyboardEvent) { - const meta = event.metaKey || event.ctrlKey; + const action = matchShortcut(event); - if (meta && event.key === "Enter") { - event.preventDefault(); - run(); + if (!action) + return; + + event.preventDefault(); + + switch (action.type) { + case "closeTab": + session.closeTab(session.activeId); + break; + case "format": + session.formatActive(); + break; + case "newTab": + session.addTab(); + break; + case "nextTab": + session.nextTab(); + break; + case "prevTab": + session.prevTab(); + break; + case "run": + run(); + break; } } function onBeforeUnload() { session.persist(); } + + function onHistoryDragStart() { + dragStartHistoryWidth = historyWidth; + } + + function onHistoryDrag(dx: number) { + historyWidth = clamp(dragStartHistoryWidth + dx, 200, 500); + } + + function onHistoryKeyAdjust(delta: number) { + historyWidth = clamp(historyWidth + delta, 200, 500); + } + + function onDocsDragStart() { + dragStartDocsWidth = docsWidth; + } + + function onDocsDrag(dx: number) { + docsWidth = clamp(dragStartDocsWidth - dx, 240, 600); + } + + function onDocsKeyAdjust(delta: number) { + docsWidth = clamp(docsWidth - delta, 240, 600); + } + + function onLeftDragStart() { + dragStartLeftWidth = leftWidth; + dragStartLeftWidthPx = centerEl?.getBoundingClientRect().width ?? 0; + } + + function onLeftDrag(dx: number) { + if (dragStartLeftWidthPx === 0) + return; + + const percentDelta = (dx / dragStartLeftWidthPx) * 100; + leftWidth = clamp(dragStartLeftWidth + percentDelta, 20, 80); + } + + function onLeftKeyAdjust(delta: number) { + leftWidth = clamp(leftWidth + delta, 20, 80); + } + + function onBottomDragStart() { + dragStartBottomHeight = bottomHeight; + dragStartBottomHeightPx = leftEl?.getBoundingClientRect().height ?? 0; + } + + function onBottomDrag(_dx: number, dy: number) { + if (dragStartBottomHeightPx === 0) + return; + + const percentDelta = (dy / dragStartBottomHeightPx) * 100; + bottomHeight = clamp(dragStartBottomHeight - percentDelta, 15, 70); + } + + function onBottomKeyAdjust(delta: number) { + bottomHeight = clamp(bottomHeight - delta, 15, 70); + } </script> <style lang="scss"> @@ -191,27 +362,34 @@ .panes { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr; min-height: 0; &.history-open { - grid-template-columns: 260px 1fr 1fr; + grid-template-columns: var(--graphiql-history-width) 6px 1fr; } &.docs-open { - grid-template-columns: 1fr 1fr 320px; + grid-template-columns: 1fr 6px var(--graphiql-docs-width); } &.history-open.docs-open { - grid-template-columns: 260px 1fr 1fr 320px; + grid-template-columns: var(--graphiql-history-width) 6px 1fr 6px var(--graphiql-docs-width); } } + .center { + display: grid; + grid-template-columns: var(--graphiql-left-width) 6px calc(100% - var(--graphiql-left-width) - 6px); + min-height: 0; + min-width: 0; + } + .left { - border-right: 1px solid var(--graphiql-border, #333); display: grid; - grid-template-rows: 2fr auto 1fr; + grid-template-rows: 1fr auto 6px var(--graphiql-bottom-height); min-height: 0; + min-width: 0; } .query { @@ -267,6 +445,7 @@ {docsOpen} extras={toolbarExtras} {historyOpen} + onFormat={() => session.formatActive()} onRun={run} onSelectOperation={(name) => { if (session.active) @@ -286,53 +465,93 @@ onRename={(id, title) => session.renameTab(id, title)} onSelect={(id) => session.selectTab(id)} tabs={session.tabs}/> - <div class="panes" class:docs-open={docsOpen && schema.schema} class:history-open={historyOpen}> + <div + class="panes" + class:docs-open={docsOpen && schema.schema} + class:history-open={historyOpen} + style="--graphiql-history-width: {historyWidth}px; --graphiql-docs-width: {docsWidth}px;" + > {#if historyOpen} <HistoryPanel entries={history.entries} + notice={historyNotice} onClear={() => history.clear()} + onDismissNotice={() => (historyNotice = null)} + onExport={onExportSession} onFavorite={(id) => history.favorite(id)} + onImport={onImportSession} onLoad={loadHistory} onRemove={(id) => history.remove(id)}/> + <Splitter + onDrag={onHistoryDrag} + onDragStart={onHistoryDragStart} + onKeyAdjust={onHistoryKeyAdjust} + orientation="horizontal"/> {/if} - <div class="left"> - <div class="query"> - <Editor - language="graphql" - onChange={onQueryChange} - schema={schema.sdl} - {theme} - value={session.active?.query ?? ""}/> - </div> - <div class="switcher"> - <button - class="switch" - class:active={bottomPane === "variables"} - onclick={() => (bottomPane = "variables")}>Variables</button> - <button - class="switch" - class:active={bottomPane === "headers"} - onclick={() => (bottomPane = "headers")}>Headers</button> - </div> - <div class="bottom"> - {#if bottomPane === "variables"} + <div + bind:this={centerEl} + class="center" + style="--graphiql-left-width: {leftWidth}%;" + > + <div bind:this={leftEl} class="left" style="--graphiql-bottom-height: {bottomHeight}%;"> + <div class="query"> <Editor - language="json" - onChange={onVariablesChange} - {theme} - value={session.active?.variables ?? "{}"}/> - {:else} - <HeadersEditor - onChange={onHeadersChange} + language="graphql" + onChange={onQueryChange} + schema={schema.sdl} {theme} - value={session.active?.headers ?? "{}"}/> - {/if} + value={session.active?.query ?? ""}/> + </div> + <div class="switcher"> + <button + class="switch" + class:active={bottomPane === "variables"} + onclick={() => (bottomPane = "variables")}>Variables</button> + <button + class="switch" + class:active={bottomPane === "headers"} + onclick={() => (bottomPane = "headers")}>Headers</button> + </div> + <Splitter + onDrag={onBottomDrag} + onDragStart={onBottomDragStart} + onKeyAdjust={onBottomKeyAdjust} + orientation="vertical"/> + <div class="bottom"> + {#if bottomPane === "variables"} + <Editor + language="json" + onChange={onVariablesChange} + {theme} + value={session.active?.variables ?? "{}"}/> + {:else} + <HeadersEditor + onChange={onHeadersChange} + {theme} + value={session.active?.headers ?? "{}"}/> + {/if} + </div> + </div> + <Splitter + onDrag={onLeftDrag} + onDragStart={onLeftDragStart} + onKeyAdjust={onLeftKeyAdjust} + orientation="horizontal"/> + <div class="right"> + <ResultViewer + footer={resultFooter} + streamIntervals={session.active?.streamIntervals ?? []} + {theme} + timing={session.active?.timing ?? null} + value={session.active?.result ?? ""}/> </div> - </div> - <div class="right"> - <ResultViewer footer={resultFooter} {theme} value={session.active?.result ?? ""}/> </div> {#if docsOpen && schema.schema} + <Splitter + onDrag={onDocsDrag} + onDragStart={onDocsDragStart} + onKeyAdjust={onDocsKeyAdjust} + orientation="horizontal"/> <DocExplorer schema={schema.schema}/> {/if} </div> |