@openforge-app/plugin-sdk 0.2.9 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend.d.ts +2 -2
- package/dist/collapsibleSectionState.js +30 -12
- package/dist/domain.d.ts +1 -0
- package/dist/domain.js +2 -1
- package/dist/frontend.d.ts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/manifest.d.ts +1 -0
- package/dist/manifest.js +10 -4
- package/dist/markdown.d.ts +9 -0
- package/dist/markdown.js +57 -1
- package/dist/mermaid.d.ts +13 -0
- package/dist/mermaid.js +221 -0
- package/dist/mermaidZoom.d.ts +23 -0
- package/dist/mermaidZoom.js +52 -0
- package/dist/openforgePackageMetadataSchema.json +2 -11
- package/dist/publicEntrypoints.d.mts +27 -0
- package/dist/publicEntrypoints.mjs +104 -0
- package/dist/publicUiExports.mjs +12 -22
- package/dist/registryValidation.d.mts +11 -0
- package/dist/registryValidation.mjs +30 -0
- package/dist/sanitize.d.ts +2 -0
- package/dist/sanitize.js +131 -0
- package/dist/taskBrowserDevToolsShortcuts.d.ts +12 -0
- package/dist/taskBrowserDevToolsShortcuts.js +20 -0
- package/dist/testing/commonApiFake.js +240 -7
- package/dist/testing/contracts.d.ts +15 -3
- package/dist/testing/frontendContributionFake.d.ts +2 -3
- package/dist/testing/frontendContributionFake.js +0 -32
- package/dist/testing/support.d.ts +5 -2
- package/dist/testing/support.js +12 -1
- package/dist/types.d.ts +91 -20
- package/dist/types.js +1 -1
- package/dist/ui/MarkdownContent.svelte +46 -0
- package/dist/ui/MermaidDiagramPreview.svelte +216 -0
- package/dist/ui/Modal.svelte +39 -4
- package/dist/ui/PluginPageHeader.svelte +11 -4
- package/dist/ui/PluginPageShell.svelte +20 -0
- package/dist/ui/ProjectFileTree.svelte +192 -0
- package/dist/vite.js +7 -17
- package/package.json +14 -6
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Component } from 'svelte';
|
|
2
2
|
import type { BrowserSurfacesAPI } from './browserSurfaces';
|
|
3
|
-
import type { AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, ReviewPullRequest, Task, TaskWorkspaceInfo, WritableBoardStatus } from './domain';
|
|
3
|
+
import type { BoardStatus, AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, ReviewPullRequest, Task, TaskWorkspaceInfo, WorktreeSource, WritableBoardStatus } from './domain';
|
|
4
4
|
export type SupportedOpenForgeApiVersion = 1;
|
|
5
5
|
export declare const SUPPORTED_OPENFORGE_API_VERSIONS: readonly [1, ...1[]];
|
|
6
6
|
export declare const OPENFORGE_PLUGIN_API_VERSION: SupportedOpenForgeApiVersion;
|
|
@@ -17,7 +17,7 @@ export interface ValidationError {
|
|
|
17
17
|
path: string;
|
|
18
18
|
message: string;
|
|
19
19
|
}
|
|
20
|
-
declare const OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS: readonly ['commands', 'events', 'views', 'injectionPoints', 'taskPane', 'taskStart', 'settings', 'background', 'backend', 'storage', 'context', 'navigation', 'tasks', 'projects', 'fs', 'shell', 'notifications', 'attention', 'system.openUrl', 'system.writeClipboardText', 'config', 'projectConfig', 'browserSurfaces', '
|
|
20
|
+
declare const OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS: readonly ['commands', 'events', 'views', 'injectionPoints', 'taskPane', 'taskStart', 'settings', 'background', 'backend', 'storage', 'context', 'navigation', 'tasks', 'projects', 'fs', 'shell', 'notifications', 'attention', 'system.openUrl', 'system.writeClipboardText', 'config', 'projectConfig', 'browserSurfaces', 'appEnablement', 'customSidebarNavigation', 'reviewUI'];
|
|
21
21
|
export type OpenForgePluginCapability = (typeof OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS)[number];
|
|
22
22
|
export interface OpenForgePackageMetadata {
|
|
23
23
|
id: string;
|
|
@@ -74,16 +74,6 @@ export interface NavigationAPI {
|
|
|
74
74
|
get(): OpenForgeNavigationSnapshot;
|
|
75
75
|
navigate(request: OpenForgeNavigationRequest): Promise<OpenForgeNavigationSnapshot>;
|
|
76
76
|
}
|
|
77
|
-
export interface TaskLinkOpenRequest {
|
|
78
|
-
taskId: string;
|
|
79
|
-
url: string;
|
|
80
|
-
}
|
|
81
|
-
export type TaskLinkHandlerResult = 'handled' | 'declined';
|
|
82
|
-
export type TaskLinkHandler = (request: TaskLinkOpenRequest) => Promise<TaskLinkHandlerResult>;
|
|
83
|
-
export interface TaskLinksAPI {
|
|
84
|
-
open(request: TaskLinkOpenRequest): Promise<void>;
|
|
85
|
-
registerHandler(handler: TaskLinkHandler): Disposable;
|
|
86
|
-
}
|
|
87
77
|
export interface OpenForgePluginContext {
|
|
88
78
|
pluginId: string;
|
|
89
79
|
apiVersion: SupportedOpenForgeApiVersion;
|
|
@@ -409,6 +399,9 @@ export interface UserDataFileRequest {
|
|
|
409
399
|
export interface UserDataFileWriteRequest extends UserDataFileRequest {
|
|
410
400
|
content: string;
|
|
411
401
|
}
|
|
402
|
+
export interface UserDataFileAppendResult {
|
|
403
|
+
sizeBytes: number;
|
|
404
|
+
}
|
|
412
405
|
export interface ExternalReadDirectoryRequest {
|
|
413
406
|
root: string;
|
|
414
407
|
path?: string | null;
|
|
@@ -417,6 +410,12 @@ export interface ExternalReadFileRequest {
|
|
|
417
410
|
root: string;
|
|
418
411
|
path: string;
|
|
419
412
|
}
|
|
413
|
+
export interface ExternalFileMetadata {
|
|
414
|
+
/** Stable while the same filesystem object is appended in place. */
|
|
415
|
+
identity: string;
|
|
416
|
+
sizeBytes: number;
|
|
417
|
+
modifiedAtMs: number | null;
|
|
418
|
+
}
|
|
420
419
|
export declare const DEFAULT_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES: number;
|
|
421
420
|
export declare const MIN_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES = 4;
|
|
422
421
|
export declare const MAX_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES: number;
|
|
@@ -425,17 +424,27 @@ export declare function resolveExternalTextFileChunkSize(chunkSizeBytes?: number
|
|
|
425
424
|
export interface ExternalReadTextFileChunksRequest extends ExternalReadFileRequest {
|
|
426
425
|
/** UTF-8 chunks contain at most this many bytes. Defaults to 64 KiB. */
|
|
427
426
|
chunkSizeBytes?: number;
|
|
427
|
+
/** First byte to read. Must be a UTF-8 code point boundary. Defaults to zero. */
|
|
428
|
+
startOffsetBytes?: number;
|
|
429
|
+
/** Maximum total bytes to read. The range end must be a UTF-8 code point boundary. */
|
|
430
|
+
maxBytes?: number;
|
|
431
|
+
/** Fails the read if the file no longer has this identity. */
|
|
432
|
+
expectedIdentity?: string;
|
|
428
433
|
/** Stops future reads. An in-flight host read may finish, but its result is discarded. */
|
|
429
434
|
signal?: AbortSignal;
|
|
430
435
|
}
|
|
431
436
|
export interface UserDataFileSystemAPI {
|
|
432
437
|
readDir(request?: UserDataDirectoryRequest): Promise<FileEntry[]>;
|
|
433
438
|
readTextFile(request: UserDataFileRequest): Promise<string>;
|
|
439
|
+
/** Atomically replaces the file and syncs its contents before resolving. */
|
|
434
440
|
writeTextFile(request: UserDataFileWriteRequest): Promise<void>;
|
|
441
|
+
/** Appends and syncs content before returning the resulting UTF-8 byte size. */
|
|
442
|
+
appendTextFile(request: UserDataFileWriteRequest): Promise<UserDataFileAppendResult>;
|
|
435
443
|
}
|
|
436
444
|
export interface ExternalReadFileSystemAPI {
|
|
437
445
|
readDir(request: ExternalReadDirectoryRequest): Promise<FileEntry[]>;
|
|
438
446
|
readTextFile(request: ExternalReadFileRequest): Promise<string>;
|
|
447
|
+
stat(request: ExternalReadFileRequest): Promise<ExternalFileMetadata>;
|
|
439
448
|
/** Lazily reads a UTF-8 file without retaining a host file handle between chunks. */
|
|
440
449
|
readTextFileChunks(request: ExternalReadTextFileChunksRequest): AsyncIterable<string>;
|
|
441
450
|
}
|
|
@@ -457,10 +466,6 @@ export interface ShellSpawnRequest extends ShellSessionRequest {
|
|
|
457
466
|
export interface ShellWriteRequest extends ShellSessionRequest {
|
|
458
467
|
data: string;
|
|
459
468
|
}
|
|
460
|
-
export interface ShellTerminalQueryResponseRequest extends ShellSessionRequest {
|
|
461
|
-
ptyInstanceId: number;
|
|
462
|
-
data: string;
|
|
463
|
-
}
|
|
464
469
|
export interface ShellResizeRequest extends ShellSessionRequest {
|
|
465
470
|
cols: number;
|
|
466
471
|
rows: number;
|
|
@@ -469,9 +474,9 @@ export interface TerminalViewSnapshot {
|
|
|
469
474
|
instanceId: number;
|
|
470
475
|
watermark: number;
|
|
471
476
|
data: string;
|
|
477
|
+
compatibilityData?: string;
|
|
472
478
|
}
|
|
473
479
|
export interface PtyBufferState {
|
|
474
|
-
authority?: 'xterm-authoritative' | 'ghostty-authoritative';
|
|
475
480
|
buffer: string | null;
|
|
476
481
|
snapshot?: TerminalViewSnapshot | null;
|
|
477
482
|
isLive: boolean;
|
|
@@ -480,7 +485,6 @@ export interface PtyBufferState {
|
|
|
480
485
|
export interface ShellAPI {
|
|
481
486
|
spawn(request: ShellSpawnRequest): Promise<number>;
|
|
482
487
|
write(request: ShellWriteRequest): Promise<void>;
|
|
483
|
-
writeTerminalQueryResponse(request: ShellTerminalQueryResponseRequest): Promise<void>;
|
|
484
488
|
resize(request: ShellResizeRequest): Promise<void>;
|
|
485
489
|
kill(request: ShellSessionRequest): Promise<void>;
|
|
486
490
|
getBuffer(request: ShellSessionRequest): Promise<PtyBufferState>;
|
|
@@ -497,6 +501,14 @@ export interface ComposeTaskRequest {
|
|
|
497
501
|
initialPrompt: string;
|
|
498
502
|
sourceTicketUrl?: string | null;
|
|
499
503
|
title?: string | null;
|
|
504
|
+
/** Seeds the dialog's worktree source instead of the project default. */
|
|
505
|
+
worktreeSource?: WorktreeSource | null;
|
|
506
|
+
/**
|
|
507
|
+
* When `worktreeSource` is `existingBranch`, seeds the branch selector.
|
|
508
|
+
* Pull-request head refs may be short names; the host maps them onto the
|
|
509
|
+
* stored selector value (`origin/<name>` when origin has the branch).
|
|
510
|
+
*/
|
|
511
|
+
worktreeBranch?: string | null;
|
|
500
512
|
}
|
|
501
513
|
export interface ComposeTaskResult {
|
|
502
514
|
task: Task;
|
|
@@ -542,6 +554,61 @@ export declare class TaskFollowUpError extends Error {
|
|
|
542
554
|
readonly code: TaskFollowUpErrorCode;
|
|
543
555
|
constructor(code: TaskFollowUpErrorCode, message: string);
|
|
544
556
|
}
|
|
557
|
+
export interface ListTaskSessionsRequest {
|
|
558
|
+
taskId: string;
|
|
559
|
+
/** Inclusive Unix timestamp in seconds. Omit to return the Task's full Agent Session history. */
|
|
560
|
+
createdAtOrAfter?: number;
|
|
561
|
+
/** Open-ended provider identifier such as `pi`. Omit to include every provider. */
|
|
562
|
+
provider?: string;
|
|
563
|
+
}
|
|
564
|
+
export declare const MAX_AGENT_SESSION_PAGE_SIZE = 250;
|
|
565
|
+
export type AgentSessionCursor = string;
|
|
566
|
+
export interface AgentSessionOverlap {
|
|
567
|
+
/** Inclusive lower bound as a Unix timestamp in seconds. */
|
|
568
|
+
startInclusive: number;
|
|
569
|
+
/** Exclusive upper bound as a Unix timestamp in seconds. */
|
|
570
|
+
endExclusive: number;
|
|
571
|
+
}
|
|
572
|
+
export interface ListAgentSessionsRequest {
|
|
573
|
+
/** Open-ended provider identifier such as `pi`. */
|
|
574
|
+
provider: string;
|
|
575
|
+
overlaps: AgentSessionOverlap;
|
|
576
|
+
/** Restrict the query to one Task without enumerating unrelated Tasks. */
|
|
577
|
+
taskId?: string;
|
|
578
|
+
/** Opaque cursor returned by the preceding page. */
|
|
579
|
+
cursor?: AgentSessionCursor;
|
|
580
|
+
/** Number of Agent Sessions to return, from 1 through 250. */
|
|
581
|
+
pageSize: number;
|
|
582
|
+
}
|
|
583
|
+
export interface AgentSessionTaskSummary {
|
|
584
|
+
id: string;
|
|
585
|
+
title: string;
|
|
586
|
+
status: BoardStatus;
|
|
587
|
+
createdAt: number;
|
|
588
|
+
updatedAt: number;
|
|
589
|
+
}
|
|
590
|
+
export interface AgentSessionWorkspace {
|
|
591
|
+
rootPath: string;
|
|
592
|
+
kind: 'project' | 'worktree';
|
|
593
|
+
}
|
|
594
|
+
export interface AgentSessionSummary {
|
|
595
|
+
/** OpenForge Agent Session ID. */
|
|
596
|
+
id: string;
|
|
597
|
+
provider: string;
|
|
598
|
+
/** Provider-owned Agent Session ID, or null when the host has none. */
|
|
599
|
+
providerSessionId: string | null;
|
|
600
|
+
createdAt: number;
|
|
601
|
+
updatedAt: number;
|
|
602
|
+
task: AgentSessionTaskSummary;
|
|
603
|
+
workspace: AgentSessionWorkspace | null;
|
|
604
|
+
}
|
|
605
|
+
export interface AgentSessionSummaryPage {
|
|
606
|
+
items: AgentSessionSummary[];
|
|
607
|
+
nextCursor: AgentSessionCursor | null;
|
|
608
|
+
}
|
|
609
|
+
export interface AgentSessionsAPI {
|
|
610
|
+
list(request: ListAgentSessionsRequest): Promise<AgentSessionSummaryPage>;
|
|
611
|
+
}
|
|
545
612
|
export interface TasksAPI {
|
|
546
613
|
/**
|
|
547
614
|
* Lists tasks, optionally scoped to a project. By default done tasks are
|
|
@@ -559,7 +626,9 @@ export interface TasksAPI {
|
|
|
559
626
|
/**
|
|
560
627
|
* Opens the host's create-task dialog pre-filled, letting the user edit the
|
|
561
628
|
* prompt — including anything contributed at that injection point —
|
|
562
|
-
* before the task exists.
|
|
629
|
+
* before the task exists. Optional `worktreeSource` / `worktreeBranch` seed
|
|
630
|
+
* the environment controls the same way `title` and `sourceTicketUrl` seed
|
|
631
|
+
* their fields.
|
|
563
632
|
* Resolves null if they dismiss it.
|
|
564
633
|
*/
|
|
565
634
|
compose(request: ComposeTaskRequest): Promise<ComposeTaskResult | null>;
|
|
@@ -570,6 +639,8 @@ export interface TasksAPI {
|
|
|
570
639
|
sendFollowUp(request: SendTaskFollowUpRequest): Promise<TaskFollowUpReceipt>;
|
|
571
640
|
getWorkspace(taskId: string): Promise<TaskWorkspaceInfo | null>;
|
|
572
641
|
getLatestSession(taskId: string): Promise<AgentSession | null>;
|
|
642
|
+
/** Returns matching Agent Sessions newest first. */
|
|
643
|
+
listSessions(request: ListTaskSessionsRequest): Promise<AgentSession[]>;
|
|
573
644
|
}
|
|
574
645
|
export interface ProjectsAPI {
|
|
575
646
|
list(): Promise<Project[]>;
|
|
@@ -601,6 +672,7 @@ export interface OpenForgeCommonAPI {
|
|
|
601
672
|
context: {
|
|
602
673
|
getSnapshot(): OpenForgeContextSnapshot;
|
|
603
674
|
};
|
|
675
|
+
agentSessions: AgentSessionsAPI;
|
|
604
676
|
tasks: TasksAPI;
|
|
605
677
|
projects: ProjectsAPI;
|
|
606
678
|
fs: FileSystemAPI;
|
|
@@ -613,7 +685,6 @@ export interface OpenForgeCommonAPI {
|
|
|
613
685
|
}
|
|
614
686
|
export interface FrontendOpenForgeAPI extends OpenForgeCommonAPI {
|
|
615
687
|
browserSurfaces: BrowserSurfacesAPI;
|
|
616
|
-
taskLinks: TaskLinksAPI;
|
|
617
688
|
navigation: NavigationAPI;
|
|
618
689
|
views: FrontendViewRegistry;
|
|
619
690
|
taskUI: FrontendTaskUIRegistry;
|
package/dist/types.js
CHANGED
|
@@ -34,7 +34,6 @@ const OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS = [
|
|
|
34
34
|
'config',
|
|
35
35
|
'projectConfig',
|
|
36
36
|
'browserSurfaces',
|
|
37
|
-
'taskLinks',
|
|
38
37
|
'appEnablement',
|
|
39
38
|
'customSidebarNavigation',
|
|
40
39
|
'reviewUI',
|
|
@@ -71,6 +70,7 @@ export class TaskFollowUpError extends Error {
|
|
|
71
70
|
this.code = code;
|
|
72
71
|
}
|
|
73
72
|
}
|
|
73
|
+
export const MAX_AGENT_SESSION_PAGE_SIZE = 250;
|
|
74
74
|
export function makePluginViewKey(pluginId, viewId) {
|
|
75
75
|
return `plugin:${pluginId}:${viewId}`;
|
|
76
76
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onDestroy } from 'svelte'
|
|
3
|
+
import { getRenderedMermaidDiagram, getRenderedMermaidSvg, observeMermaidTheme, renderMermaidDiagrams, type RenderedMermaidDiagram } from '../mermaid'
|
|
4
|
+
import MermaidDiagramPreview from './MermaidDiagramPreview.svelte'
|
|
3
5
|
import {
|
|
4
6
|
getMarkdownRepositoryLinkSuffix,
|
|
5
7
|
MARKDOWN_REMOTE_MEDIA_ATTRIBUTE,
|
|
@@ -37,7 +39,11 @@
|
|
|
37
39
|
}: Props = $props()
|
|
38
40
|
|
|
39
41
|
let root = $state<HTMLDivElement | null>(null)
|
|
42
|
+
let activeMermaidDiagram = $state<RenderedMermaidDiagram | null>(null)
|
|
40
43
|
let imageResolutionId = 0
|
|
44
|
+
let mermaidRenderId = 0
|
|
45
|
+
let mermaidThemeRevision = $state(0)
|
|
46
|
+
let stopObservingMermaidTheme: (() => void) | undefined
|
|
41
47
|
let html = $derived(renderMarkdownHtml(content, {
|
|
42
48
|
imageBaseUrl: resolveRepositoryImage ? null : imageBaseUrl,
|
|
43
49
|
markdownFilePath,
|
|
@@ -137,6 +143,31 @@
|
|
|
137
143
|
}))
|
|
138
144
|
}
|
|
139
145
|
|
|
146
|
+
function synchronizeActiveMermaidDiagram() {
|
|
147
|
+
if (!activeMermaidDiagram) return
|
|
148
|
+
|
|
149
|
+
const svg = getRenderedMermaidSvg(activeMermaidDiagram.wrapper)
|
|
150
|
+
activeMermaidDiagram = svg ? { ...activeMermaidDiagram, svg } : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
$effect(() => {
|
|
154
|
+
if (!root || stopObservingMermaidTheme) return
|
|
155
|
+
stopObservingMermaidTheme = observeMermaidTheme(root.ownerDocument, () => {
|
|
156
|
+
mermaidThemeRevision++
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
$effect(() => {
|
|
161
|
+
const runId = ++mermaidRenderId
|
|
162
|
+
void html
|
|
163
|
+
void mermaidThemeRevision
|
|
164
|
+
if (!root) return
|
|
165
|
+
|
|
166
|
+
void renderMermaidDiagrams(root, () => runId === mermaidRenderId).then(() => {
|
|
167
|
+
if (runId === mermaidRenderId) synchronizeActiveMermaidDiagram()
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
140
171
|
$effect(() => {
|
|
141
172
|
const runId = ++imageResolutionId
|
|
142
173
|
void html
|
|
@@ -149,6 +180,9 @@
|
|
|
149
180
|
|
|
150
181
|
onDestroy(() => {
|
|
151
182
|
imageResolutionId++
|
|
183
|
+
mermaidRenderId++
|
|
184
|
+
activeMermaidDiagram = null
|
|
185
|
+
stopObservingMermaidTheme?.()
|
|
152
186
|
})
|
|
153
187
|
|
|
154
188
|
function openMarkdownLink(href: string, absoluteHref: string) {
|
|
@@ -190,6 +224,14 @@
|
|
|
190
224
|
function handleClick(event: MouseEvent) {
|
|
191
225
|
if (!(event.target instanceof Element)) return
|
|
192
226
|
|
|
227
|
+
const diagram = getRenderedMermaidDiagram(event.target)
|
|
228
|
+
if (diagram) {
|
|
229
|
+
event.preventDefault()
|
|
230
|
+
diagram.trigger.focus()
|
|
231
|
+
activeMermaidDiagram = diagram
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
193
235
|
const image = findEventImage(event.target)
|
|
194
236
|
if (image && onOpenImage && image.getAttribute('src')) {
|
|
195
237
|
event.preventDefault()
|
|
@@ -221,3 +263,7 @@
|
|
|
221
263
|
<div bind:this={root} role="presentation" class="markdown-body" onclick={handleClick} onkeydown={handleKeydown}>
|
|
222
264
|
{@html html}
|
|
223
265
|
</div>
|
|
266
|
+
|
|
267
|
+
{#if activeMermaidDiagram}
|
|
268
|
+
<MermaidDiagramPreview svg={activeMermaidDiagram.svg} onClose={() => { activeMermaidDiagram = null }} />
|
|
269
|
+
{/if}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte'
|
|
3
|
+
import {
|
|
4
|
+
FIT_MERMAID_ZOOM,
|
|
5
|
+
calculateMermaidFitScale,
|
|
6
|
+
canZoomMermaidIn,
|
|
7
|
+
canZoomMermaidOut,
|
|
8
|
+
formatMermaidZoomLabel,
|
|
9
|
+
resetMermaidZoom,
|
|
10
|
+
resolveMermaidZoomScale,
|
|
11
|
+
zoomMermaidIn,
|
|
12
|
+
zoomMermaidOut,
|
|
13
|
+
type MermaidSize,
|
|
14
|
+
type MermaidZoomState,
|
|
15
|
+
} from '../mermaidZoom'
|
|
16
|
+
import Modal from './Modal.svelte'
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
svg: string
|
|
20
|
+
onClose: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let { svg, onClose }: Props = $props()
|
|
24
|
+
let viewport = $state<HTMLDivElement | null>(null)
|
|
25
|
+
let svgHost = $state<HTMLDivElement | null>(null)
|
|
26
|
+
let closeButton = $state<HTMLButtonElement | null>(null)
|
|
27
|
+
let viewportSize = $state<MermaidSize>({ width: 0, height: 0 })
|
|
28
|
+
let zoom = $state<MermaidZoomState>(FIT_MERMAID_ZOOM)
|
|
29
|
+
let resizeObserver: ResizeObserver | undefined
|
|
30
|
+
|
|
31
|
+
function readSvgSize(markup: string): MermaidSize | null {
|
|
32
|
+
const template = document.createElement('template')
|
|
33
|
+
template.innerHTML = markup
|
|
34
|
+
const element = template.content.querySelector('svg')
|
|
35
|
+
if (!element) return null
|
|
36
|
+
|
|
37
|
+
const viewBox = element.getAttribute('viewBox')
|
|
38
|
+
?.trim()
|
|
39
|
+
.split(/[\s,]+/)
|
|
40
|
+
.map(Number)
|
|
41
|
+
if (viewBox?.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) {
|
|
42
|
+
return { width: viewBox[2], height: viewBox[3] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const width = Number.parseFloat(element.getAttribute('width') ?? '')
|
|
46
|
+
const height = Number.parseFloat(element.getAttribute('height') ?? '')
|
|
47
|
+
return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
|
|
48
|
+
? { width, height }
|
|
49
|
+
: null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let diagramSize = $derived(readSvgSize(svg))
|
|
53
|
+
let fitScale = $derived(diagramSize
|
|
54
|
+
? calculateMermaidFitScale(diagramSize, viewportSize)
|
|
55
|
+
: null)
|
|
56
|
+
let renderedScale = $derived(resolveMermaidZoomScale(zoom, fitScale))
|
|
57
|
+
let zoomLabel = $derived(formatMermaidZoomLabel(zoom, fitScale))
|
|
58
|
+
let renderedWidth = $derived(diagramSize ? diagramSize.width * renderedScale : null)
|
|
59
|
+
let renderedHeight = $derived(diagramSize ? diagramSize.height * renderedScale : null)
|
|
60
|
+
|
|
61
|
+
function updateViewportSize(width: number, height: number) {
|
|
62
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return
|
|
63
|
+
viewportSize = { width, height }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
$effect(() => {
|
|
67
|
+
if (!viewport || resizeObserver) return
|
|
68
|
+
|
|
69
|
+
const bounds = viewport.getBoundingClientRect()
|
|
70
|
+
updateViewportSize(bounds.width, bounds.height)
|
|
71
|
+
if (typeof ResizeObserver !== 'function') return
|
|
72
|
+
|
|
73
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
74
|
+
const entry = entries.find(candidate => candidate.target === viewport)
|
|
75
|
+
if (entry) updateViewportSize(entry.contentRect.width, entry.contentRect.height)
|
|
76
|
+
})
|
|
77
|
+
resizeObserver.observe(viewport)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
$effect(() => {
|
|
81
|
+
void svg
|
|
82
|
+
if (!svgHost || renderedWidth === null || renderedHeight === null) return
|
|
83
|
+
|
|
84
|
+
const element = svgHost.querySelector('svg') as SVGSVGElement | null
|
|
85
|
+
if (!element) return
|
|
86
|
+
element.style.display = 'block'
|
|
87
|
+
element.style.width = `${renderedWidth}px`
|
|
88
|
+
element.style.height = `${renderedHeight}px`
|
|
89
|
+
element.style.maxWidth = 'none'
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
onDestroy(() => {
|
|
93
|
+
resizeObserver?.disconnect()
|
|
94
|
+
resizeObserver = undefined
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
function zoomIn() {
|
|
98
|
+
zoom = zoomMermaidIn(zoom, fitScale)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function zoomOut() {
|
|
102
|
+
zoom = zoomMermaidOut(zoom, fitScale)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resetZoom() {
|
|
106
|
+
zoom = resetMermaidZoom()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function fitToWindow() {
|
|
110
|
+
zoom = FIT_MERMAID_ZOOM
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function handleKeydown(event: KeyboardEvent): boolean | void {
|
|
114
|
+
if (event.metaKey || event.ctrlKey || event.altKey || event.defaultPrevented) return
|
|
115
|
+
|
|
116
|
+
if (event.key === '+' || event.key === '=') {
|
|
117
|
+
if (canZoomMermaidIn(zoom, fitScale)) zoomIn()
|
|
118
|
+
} else if (event.key === '-') {
|
|
119
|
+
if (canZoomMermaidOut(zoom, fitScale)) zoomOut()
|
|
120
|
+
} else if (event.key === '0') {
|
|
121
|
+
resetZoom()
|
|
122
|
+
} else if (event.key.toLowerCase() === 'f') {
|
|
123
|
+
fitToWindow()
|
|
124
|
+
} else {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
event.preventDefault()
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<Modal
|
|
134
|
+
{onClose}
|
|
135
|
+
ariaLabel="Mermaid diagram preview"
|
|
136
|
+
showHeader={false}
|
|
137
|
+
maxWidth="calc(100vw - 2rem)"
|
|
138
|
+
boxClass="mermaid-diagram-preview h-[calc(100vh-2rem)] !max-h-[calc(100vh-2rem)] w-[calc(100vw-2rem)]"
|
|
139
|
+
initialFocus={() => closeButton}
|
|
140
|
+
onKeydown={handleKeydown}
|
|
141
|
+
>
|
|
142
|
+
<div class="flex min-h-0 flex-1 flex-col bg-base-300/40">
|
|
143
|
+
<header class="mermaid-diagram-preview-toolbar flex min-h-14 shrink-0 items-center gap-2 border-b border-base-300 bg-base-100 px-4 py-2">
|
|
144
|
+
<h2 class="m-0 min-w-0 flex-1 truncate text-sm font-semibold text-base-content">Mermaid diagram preview</h2>
|
|
145
|
+
|
|
146
|
+
<div class="flex items-center gap-1" role="group" aria-label="Diagram zoom controls">
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
class="btn btn-ghost btn-sm h-11 min-h-11 w-11 p-0"
|
|
150
|
+
aria-label="Zoom out"
|
|
151
|
+
title="Zoom out (-)"
|
|
152
|
+
disabled={!canZoomMermaidOut(zoom, fitScale)}
|
|
153
|
+
onclick={zoomOut}
|
|
154
|
+
>
|
|
155
|
+
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
156
|
+
<circle cx="11" cy="11" r="8" />
|
|
157
|
+
<path d="m21 21-4.3-4.3M8 11h6" />
|
|
158
|
+
</svg>
|
|
159
|
+
</button>
|
|
160
|
+
|
|
161
|
+
<output class="min-w-20 text-center text-xs tabular-nums text-base-content/70" aria-live="polite">{zoomLabel}</output>
|
|
162
|
+
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
class="btn btn-ghost btn-sm h-11 min-h-11 w-11 p-0"
|
|
166
|
+
aria-label="Zoom in"
|
|
167
|
+
title="Zoom in (+)"
|
|
168
|
+
disabled={!canZoomMermaidIn(zoom, fitScale)}
|
|
169
|
+
onclick={zoomIn}
|
|
170
|
+
>
|
|
171
|
+
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
172
|
+
<circle cx="11" cy="11" r="8" />
|
|
173
|
+
<path d="m21 21-4.3-4.3M11 8v6M8 11h6" />
|
|
174
|
+
</svg>
|
|
175
|
+
</button>
|
|
176
|
+
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
class="btn btn-ghost btn-sm h-11 min-h-11 px-3"
|
|
180
|
+
aria-label="Reset zoom to 100%"
|
|
181
|
+
title="Reset zoom to 100% (0)"
|
|
182
|
+
onclick={resetZoom}
|
|
183
|
+
>100%</button>
|
|
184
|
+
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
class="btn btn-ghost btn-sm h-11 min-h-11 px-3"
|
|
188
|
+
aria-label="Fit diagram to window"
|
|
189
|
+
aria-pressed={zoom.mode === 'fit'}
|
|
190
|
+
title="Fit diagram to window (F)"
|
|
191
|
+
onclick={fitToWindow}
|
|
192
|
+
>Fit</button>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<button
|
|
196
|
+
bind:this={closeButton}
|
|
197
|
+
type="button"
|
|
198
|
+
class="btn btn-ghost btn-sm h-11 min-h-11 w-11 p-0"
|
|
199
|
+
aria-label="Close diagram preview"
|
|
200
|
+
onclick={onClose}
|
|
201
|
+
>
|
|
202
|
+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
203
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
204
|
+
</svg>
|
|
205
|
+
</button>
|
|
206
|
+
</header>
|
|
207
|
+
|
|
208
|
+
<div bind:this={viewport} data-testid="mermaid-preview-viewport" class="mermaid-diagram-preview-viewport min-h-0 flex-1 overflow-auto p-4">
|
|
209
|
+
<div data-testid="mermaid-preview-canvas" class="mermaid-diagram-preview-canvas flex h-max min-h-full w-max min-w-full items-center justify-center">
|
|
210
|
+
<div bind:this={svgHost} class="shrink-0">
|
|
211
|
+
{@html svg}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</Modal>
|
package/dist/ui/Modal.svelte
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
export type ModalInitialFocus = HTMLElement | string | (() => HTMLElement | null | undefined) | null | undefined
|
|
6
6
|
|
|
7
|
+
type ModalAccessibleName =
|
|
8
|
+
| { ariaLabel: string; ariaLabelledby?: never }
|
|
9
|
+
| { ariaLabel?: never; ariaLabelledby: string }
|
|
10
|
+
|
|
7
11
|
interface Props {
|
|
8
12
|
onClose: () => void
|
|
9
13
|
maxWidth?: string
|
|
10
14
|
overflowVisible?: boolean
|
|
11
15
|
initialFocus?: ModalInitialFocus
|
|
12
|
-
ariaLabel?: string
|
|
13
16
|
showHeader?: boolean
|
|
14
17
|
closeLabel?: string
|
|
15
18
|
closeDisabled?: boolean
|
|
@@ -21,7 +24,34 @@
|
|
|
21
24
|
children: Snippet
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
let { onClose, maxWidth = '500px', overflowVisible = false, initialFocus, ariaLabel, showHeader = true, closeLabel = 'Close dialog', closeDisabled = false, onKeydown, testId, modalClass = '', boxClass = '', header, children }: Props = $props()
|
|
27
|
+
let { onClose, maxWidth = '500px', overflowVisible = false, initialFocus, ariaLabel, ariaLabelledby, showHeader = true, closeLabel = 'Close dialog', closeDisabled = false, onKeydown, testId, modalClass = '', boxClass = '', header, children }: Props & ModalAccessibleName = $props()
|
|
28
|
+
let accessibleNameAttributes = $derived.by(() => {
|
|
29
|
+
const hasAriaLabel = Boolean(ariaLabel?.trim())
|
|
30
|
+
const hasAriaLabelledby = Boolean(ariaLabelledby?.trim())
|
|
31
|
+
|
|
32
|
+
if (hasAriaLabel === hasAriaLabelledby) {
|
|
33
|
+
throw new Error('Modal requires exactly one non-empty accessible name prop: ariaLabel or ariaLabelledby')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
ariaLabel: hasAriaLabel ? ariaLabel : undefined,
|
|
38
|
+
ariaLabelledby: hasAriaLabelledby ? ariaLabelledby : undefined,
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
$effect(() => {
|
|
43
|
+
const idReferences = accessibleNameAttributes.ariaLabelledby?.split(/\s+/)
|
|
44
|
+
if (!idReferences) return
|
|
45
|
+
|
|
46
|
+
const hasNamingText = idReferences.some((id) => {
|
|
47
|
+
const labelledElement = document.getElementById(id)
|
|
48
|
+
return Boolean(labelledElement?.textContent?.trim() || labelledElement?.getAttribute('aria-label')?.trim())
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (!hasNamingText) {
|
|
52
|
+
throw new Error('Modal ariaLabelledby must reference at least one element with naming text')
|
|
53
|
+
}
|
|
54
|
+
})
|
|
25
55
|
|
|
26
56
|
let modalElement: HTMLDivElement | null = $state(null)
|
|
27
57
|
let hasAppliedInitialFocus = false
|
|
@@ -142,14 +172,19 @@
|
|
|
142
172
|
}
|
|
143
173
|
</script>
|
|
144
174
|
|
|
145
|
-
<div bind:this={modalElement} class="modal modal-open {modalClass}" data-testid={testId} onclick={handleOverlayClick} onkeydown={handleKeydown} role="dialog" aria-modal="true" aria-label={ariaLabel} tabindex="-1">
|
|
175
|
+
<div bind:this={modalElement} class="modal modal-open {modalClass}" data-testid={testId} onclick={handleOverlayClick} onkeydown={handleKeydown} role="dialog" aria-modal="true" aria-label={accessibleNameAttributes.ariaLabel} aria-labelledby={accessibleNameAttributes.ariaLabelledby} tabindex="-1">
|
|
146
176
|
<div class="modal-box bg-base-100 shadow-xl p-0 flex flex-col max-h-[90vh] {overflowVisible ? 'overflow-visible' : ''} {boxClass}" style="max-width: {maxWidth}">
|
|
147
177
|
{#if showHeader}
|
|
148
178
|
<div class="flex items-center justify-between px-5 py-4 border-b border-base-300">
|
|
149
179
|
{#if header}
|
|
150
180
|
{@render header()}
|
|
151
181
|
{/if}
|
|
152
|
-
<button class="btn btn-ghost
|
|
182
|
+
<button class="btn btn-ghost h-11 min-h-11 w-11 min-w-11 shrink-0 p-0" aria-label={closeLabel} onclick={handleCloseButtonClick} type="button" disabled={closeDisabled}>
|
|
183
|
+
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
|
184
|
+
<path d="M18 6 6 18" />
|
|
185
|
+
<path d="m6 6 12 12" />
|
|
186
|
+
</svg>
|
|
187
|
+
</button>
|
|
153
188
|
</div>
|
|
154
189
|
{/if}
|
|
155
190
|
{@render children()}
|
|
@@ -5,17 +5,24 @@
|
|
|
5
5
|
title: string
|
|
6
6
|
subtitle?: string | null
|
|
7
7
|
surface?: 'default' | 'subtle'
|
|
8
|
+
headingLevel?: 'h1' | 'h2'
|
|
8
9
|
actions?: Snippet
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
let {
|
|
12
|
+
let {
|
|
13
|
+
title,
|
|
14
|
+
subtitle = null,
|
|
15
|
+
surface = 'default',
|
|
16
|
+
headingLevel = 'h1',
|
|
17
|
+
actions,
|
|
18
|
+
}: Props = $props()
|
|
12
19
|
</script>
|
|
13
20
|
|
|
14
|
-
<header class="flex items-center justify-between gap-4 border-b border-base-300 px-
|
|
21
|
+
<header class="flex min-h-13 shrink-0 flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-base-300 px-4 py-2 sm:px-6 {surface === 'default' ? 'bg-base-100' : 'bg-base-200'}">
|
|
15
22
|
<div class="min-w-0">
|
|
16
|
-
<
|
|
23
|
+
<svelte:element this={headingLevel} class="m-0 truncate text-base font-semibold leading-5 tracking-[-0.01em] text-base-content">{title}</svelte:element>
|
|
17
24
|
{#if subtitle}
|
|
18
|
-
<p class="text-[13px]
|
|
25
|
+
<p class="m-0 mt-0.5 truncate text-[13px] leading-5 text-base-content/60">{subtitle}</p>
|
|
19
26
|
{/if}
|
|
20
27
|
</div>
|
|
21
28
|
{#if actions}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
header: Snippet
|
|
6
|
+
children: Snippet
|
|
7
|
+
class?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { header, children, class: className = '' }: Props = $props()
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<div class="flex h-full min-h-0 flex-col overflow-hidden bg-base-100 text-base-content {className}">
|
|
14
|
+
<div class="shrink-0">
|
|
15
|
+
{@render header()}
|
|
16
|
+
</div>
|
|
17
|
+
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
18
|
+
{@render children()}
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|