@openforge-app/plugin-sdk 0.2.10 → 0.3.0
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/README.md +220 -8
- package/dist/domain.d.ts +50 -1
- package/dist/fileIcons.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/markdown.js +7 -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/publicEntrypoints.d.mts +1 -0
- package/dist/publicEntrypoints.mjs +23 -22
- package/dist/publicUiExports.mjs +10 -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/testing/commonApiFake.d.ts +4 -2
- package/dist/testing/commonApiFake.js +247 -4
- package/dist/testing/contracts.d.ts +20 -1
- package/dist/testing/registryFake.d.ts +2 -1
- package/dist/testing/registryFake.js +3 -0
- package/dist/testing/support.d.ts +3 -1
- package/dist/testing/support.js +7 -0
- package/dist/types.d.ts +40 -9
- package/dist/ui/MarkdownContent.svelte +46 -0
- package/dist/ui/MermaidDiagramPreview.svelte +216 -0
- package/dist/ui/Modal.svelte +6 -1
- package/dist/ui/PluginSidebarLink.svelte +1 -7
- package/dist/ui/icons/video.svg +3 -0
- package/package.json +14 -5
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { BrowserSurfaceVisualFeedback } from '../browserSurfaces';
|
|
2
|
+
import type { CompletedTaskQuery } from '../domain';
|
|
2
3
|
import type { TestingOpenForgeRegistryFake } from './registryFake';
|
|
3
4
|
import type { BackendMethodRegistration, BackendOpenForgeAPI, BackgroundServiceRegistration, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ConfigureStartPromptContributionRequest, CreateTaskRequest, FrontendOpenForgeAPI, InjectionPointLocation, AgentSessionWorkspace, ListAgentSessionsRequest, ListTaskSessionsRequest, JsonValue, NotificationRequest, OpenForgeNavigationRequest, OpenForgePackageMetadata, PluginSettingsSectionRegistration, PluginStorage, PluginTaskPaneTabRegistration, PluginReviewRowActionRegistration, PluginTaskUISectionRegistration, PluginViewRegistration, ShellSpawnRequest, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskStartPrefixContext, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, UserDataDirectoryRequest, UserDataFileRequest, UserDataFileWriteRequest } from '../types';
|
|
4
|
-
import type { AgentSession, Task } from '../domain';
|
|
5
|
+
import type { AgentSession, FileContent, Task, TaskLabel } from '../domain';
|
|
5
6
|
export type TestingRuntimeScope = 'global' | 'project' | 'task';
|
|
6
7
|
export type TestingRuntimeKind = 'commands' | 'events' | 'views' | 'taskPane' | 'taskUI' | 'reviewUI' | 'settings' | 'backend' | 'background';
|
|
7
8
|
export type TestingMaybePromise<T> = T | Promise<T>;
|
|
@@ -16,6 +17,10 @@ export interface TestingExternalTextFile extends ExternalReadFileRequest {
|
|
|
16
17
|
export type TestingExternalTextFileChunksCall = Omit<ExternalReadTextFileChunksRequest, 'signal' | 'chunkSizeBytes'> & {
|
|
17
18
|
chunkSizeBytes: number;
|
|
18
19
|
};
|
|
20
|
+
export interface TestingTaskLabelAssignment {
|
|
21
|
+
taskId: string;
|
|
22
|
+
labels: TaskLabel[];
|
|
23
|
+
}
|
|
19
24
|
export interface TestingOpenForgeApiOptions {
|
|
20
25
|
pluginId?: string;
|
|
21
26
|
projectId?: string | null;
|
|
@@ -26,12 +31,16 @@ export interface TestingOpenForgeApiOptions {
|
|
|
26
31
|
storage?: PluginStorage;
|
|
27
32
|
/** Initial files exposed through `fs.userData`. Defaults to none. */
|
|
28
33
|
userDataTextFiles?: UserDataFileWriteRequest[];
|
|
34
|
+
/** File contents returned by `fs.readFile`, keyed by project-relative path. */
|
|
35
|
+
projectFileContents?: Readonly<Record<string, FileContent>>;
|
|
29
36
|
/**
|
|
30
37
|
* Tasks returned by `tasks.list`. The mock filters them by the requested
|
|
31
38
|
* `projectId` (when given) and drops `done` tasks unless `includeDone: true`,
|
|
32
39
|
* mirroring the host capability. Defaults to an empty list.
|
|
33
40
|
*/
|
|
34
41
|
tasks?: Task[];
|
|
42
|
+
/** Task Label assignments used by canonical Task projections. */
|
|
43
|
+
taskLabelAssignments?: TestingTaskLabelAssignment[];
|
|
35
44
|
/** Agent Sessions returned by `tasks.listSessions`. Defaults to an empty list. */
|
|
36
45
|
agentSessions?: AgentSession[];
|
|
37
46
|
/** Compact workspace context keyed by Task ID for `agentSessions.list`. Defaults to none. */
|
|
@@ -76,6 +85,16 @@ export interface TestingOpenForgeApiCalls {
|
|
|
76
85
|
projectId: string | null;
|
|
77
86
|
includeDone: boolean;
|
|
78
87
|
}>;
|
|
88
|
+
taskActiveRequests: Array<{
|
|
89
|
+
projectId: string;
|
|
90
|
+
}>;
|
|
91
|
+
taskCompletedRequests: Array<{
|
|
92
|
+
projectId: string;
|
|
93
|
+
} & CompletedTaskQuery>;
|
|
94
|
+
taskDetailRequests: Array<{
|
|
95
|
+
projectId: string;
|
|
96
|
+
taskId: string;
|
|
97
|
+
}>;
|
|
79
98
|
agentSessionListRequests: ListAgentSessionsRequest[];
|
|
80
99
|
taskSessionListRequests: ListTaskSessionsRequest[];
|
|
81
100
|
taskStatusUpdates: Array<{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TaskBrowserSurfaceState } from '../browserSurfaces';
|
|
2
|
-
import type { BackendPlugin, BackendPluginContext, FrontendPlugin, FrontendPluginContext, OpenForgePackageMetadata, PluginStorage } from '../types';
|
|
2
|
+
import type { BackendPlugin, BackendPluginContext, FrontendPlugin, FrontendPluginContext, OpenForgePackageMetadata, TaskChangeEvent, PluginStorage } from '../types';
|
|
3
3
|
import type { MockBackendOpenForgeAPI, MockFrontendOpenForgeAPI, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingOpenForgeRegistrySnapshot } from './contracts';
|
|
4
4
|
import { TestingSubscriptionSink } from './support.js';
|
|
5
5
|
export declare class TestingOpenForgeRegistryFake {
|
|
@@ -28,6 +28,7 @@ export declare class TestingOpenForgeRegistryFake {
|
|
|
28
28
|
createBackendContext(): BackendPluginContext;
|
|
29
29
|
activateFrontend(plugin: FrontendPlugin): Promise<void>;
|
|
30
30
|
activateBackend(plugin: BackendPlugin): Promise<void>;
|
|
31
|
+
emitTaskChange(event: TaskChangeEvent): void;
|
|
31
32
|
setBrowserSurfaceState(taskId: string, id: string, patch: Partial<TaskBrowserSurfaceState>): void;
|
|
32
33
|
disposeAll(): Promise<void>;
|
|
33
34
|
getSnapshot(): TestingOpenForgeRegistrySnapshot;
|
|
@@ -82,6 +82,9 @@ export class TestingOpenForgeRegistryFake {
|
|
|
82
82
|
await plugin.activate(this.backendApi, this.createBackendContext());
|
|
83
83
|
await this.backendServices.startNewBackgroundServices(existingServices);
|
|
84
84
|
}
|
|
85
|
+
emitTaskChange(event) {
|
|
86
|
+
this.commonApi.emitTaskChange(event);
|
|
87
|
+
}
|
|
85
88
|
setBrowserSurfaceState(taskId, id, patch) {
|
|
86
89
|
this.frontendContributions.setBrowserSurfaceState(taskId, id, patch);
|
|
87
90
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentSessionWorkspace, AgentCommandMetadata, CommandDescriptor, Disposable, JsonValue, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, OpenForgePackageMetadata, PluginStorage, StartPromptContribution, SubscriptionSink } from '../types';
|
|
2
|
-
import type { AgentSession, Task } from '../domain';
|
|
2
|
+
import type { AgentSession, FileContent, Task, TaskLabel } from '../domain';
|
|
3
3
|
import type { TestingCommandContribution, TestingMaybePromise, TestingExternalTextFile, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingRuntimeKind } from './contracts';
|
|
4
4
|
export declare function createDisposable(dispose: () => TestingMaybePromise<void>): Disposable;
|
|
5
5
|
export declare class TestingSubscriptionSink implements SubscriptionSink {
|
|
@@ -31,10 +31,12 @@ export declare class TestingRegistryServices {
|
|
|
31
31
|
readonly storage: PluginStorage;
|
|
32
32
|
readonly config: Map<string, JsonValue>;
|
|
33
33
|
readonly seededTasks: Task[];
|
|
34
|
+
readonly seededTaskLabelAssignments: Map<string, TaskLabel[]>;
|
|
34
35
|
readonly seededAgentSessions: AgentSession[];
|
|
35
36
|
readonly agentSessionWorkspaces: Readonly<Record<string, AgentSessionWorkspace>>;
|
|
36
37
|
readonly externalTextFiles: TestingExternalTextFile[];
|
|
37
38
|
readonly userDataTextFiles: Map<string, string>;
|
|
39
|
+
readonly projectFileContents: Readonly<Record<string, FileContent>>;
|
|
38
40
|
readonly claims: TestingContributionClaims;
|
|
39
41
|
constructor(options?: TestingOpenForgeApiOptions);
|
|
40
42
|
localQualifiedId(kind: TestingRuntimeKind, id: string): string;
|
package/dist/testing/support.js
CHANGED
|
@@ -44,6 +44,9 @@ export function createTestingCalls() {
|
|
|
44
44
|
taskImplementationStarts: [],
|
|
45
45
|
taskFollowUps: [],
|
|
46
46
|
taskListRequests: [],
|
|
47
|
+
taskActiveRequests: [],
|
|
48
|
+
taskCompletedRequests: [],
|
|
49
|
+
taskDetailRequests: [],
|
|
47
50
|
agentSessionListRequests: [],
|
|
48
51
|
taskSessionListRequests: [],
|
|
49
52
|
taskStatusUpdates: [],
|
|
@@ -205,10 +208,12 @@ export class TestingRegistryServices {
|
|
|
205
208
|
storage;
|
|
206
209
|
config = new Map();
|
|
207
210
|
seededTasks;
|
|
211
|
+
seededTaskLabelAssignments;
|
|
208
212
|
seededAgentSessions;
|
|
209
213
|
agentSessionWorkspaces;
|
|
210
214
|
externalTextFiles;
|
|
211
215
|
userDataTextFiles = new Map();
|
|
216
|
+
projectFileContents;
|
|
212
217
|
claims = new TestingContributionClaims();
|
|
213
218
|
constructor(options = {}) {
|
|
214
219
|
this.pluginId = options.pluginId ?? 'test-plugin';
|
|
@@ -224,9 +229,11 @@ export class TestingRegistryServices {
|
|
|
224
229
|
this.calls = createTestingCalls();
|
|
225
230
|
this.storage = options.storage ?? createMemoryPluginStorage(this.calls);
|
|
226
231
|
this.seededTasks = options.tasks ?? [];
|
|
232
|
+
this.seededTaskLabelAssignments = new Map((options.taskLabelAssignments ?? []).map(assignment => [assignment.taskId, assignment.labels]));
|
|
227
233
|
this.seededAgentSessions = options.agentSessions ?? [];
|
|
228
234
|
this.agentSessionWorkspaces = options.agentSessionWorkspaces ?? {};
|
|
229
235
|
this.externalTextFiles = options.externalTextFiles ?? [];
|
|
236
|
+
this.projectFileContents = options.projectFileContents ?? {};
|
|
230
237
|
for (const file of options.userDataTextFiles ?? []) {
|
|
231
238
|
this.userDataTextFiles.set(file.path, file.content);
|
|
232
239
|
}
|
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 { BoardStatus, AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, ReviewPullRequest, Task, TaskWorkspaceInfo, WritableBoardStatus } from './domain';
|
|
3
|
+
import type { BoardStatus, AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, ActiveTasks, CompletedTaskPage, CompletedTaskQuery, TaskRead, TaskDetail, 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;
|
|
@@ -202,6 +202,7 @@ export interface PluginTaskPaneProps extends Record<string, unknown> {
|
|
|
202
202
|
api: FrontendOpenForgeAPI;
|
|
203
203
|
context: OpenForgeContextSnapshot;
|
|
204
204
|
taskId: string;
|
|
205
|
+
task: TaskDetail;
|
|
205
206
|
projectId: string | null;
|
|
206
207
|
}
|
|
207
208
|
export interface PluginTaskUISectionProps extends PluginTaskPaneProps {
|
|
@@ -501,6 +502,14 @@ export interface ComposeTaskRequest {
|
|
|
501
502
|
initialPrompt: string;
|
|
502
503
|
sourceTicketUrl?: string | null;
|
|
503
504
|
title?: string | null;
|
|
505
|
+
/** Seeds the dialog's worktree source instead of the project default. */
|
|
506
|
+
worktreeSource?: WorktreeSource | null;
|
|
507
|
+
/**
|
|
508
|
+
* When `worktreeSource` is `existingBranch`, seeds the branch selector.
|
|
509
|
+
* Pull-request head refs may be short names; the host maps them onto the
|
|
510
|
+
* stored selector value (`origin/<name>` when origin has the branch).
|
|
511
|
+
*/
|
|
512
|
+
worktreeBranch?: string | null;
|
|
504
513
|
}
|
|
505
514
|
export interface ComposeTaskResult {
|
|
506
515
|
task: Task;
|
|
@@ -601,24 +610,38 @@ export interface AgentSessionSummaryPage {
|
|
|
601
610
|
export interface AgentSessionsAPI {
|
|
602
611
|
list(request: ListAgentSessionsRequest): Promise<AgentSessionSummaryPage>;
|
|
603
612
|
}
|
|
604
|
-
export
|
|
613
|
+
export type TaskChangeReason = 'created' | 'updated' | 'completed' | 'attention' | 'execution';
|
|
614
|
+
/**
|
|
615
|
+
* A coalescible signal that one or more bounded Task reads may now be stale.
|
|
616
|
+
* It never contains a Task snapshot.
|
|
617
|
+
*/
|
|
618
|
+
export interface TaskChangeEvent {
|
|
619
|
+
projectId: string;
|
|
620
|
+
taskId: string | null;
|
|
621
|
+
reason: TaskChangeReason;
|
|
622
|
+
}
|
|
623
|
+
export interface TaskOperationsAPI {
|
|
605
624
|
/**
|
|
606
|
-
* Lists
|
|
607
|
-
*
|
|
608
|
-
* `
|
|
609
|
-
* unscoped listing (no `projectId`) always returns all states, so
|
|
610
|
-
* `includeDone` only affects the project-scoped path.
|
|
625
|
+
* Lists legacy Task rows. Project-scoped reads exclude Completed Tasks unless
|
|
626
|
+
* `includeDone` is true; unscoped reads preserve the complete legacy array.
|
|
627
|
+
* @deprecated Use `active`, `completed`, or `detail`. Removed in version 2.
|
|
611
628
|
*/
|
|
612
629
|
list(request?: {
|
|
613
630
|
projectId?: string | null;
|
|
614
631
|
includeDone?: boolean;
|
|
615
632
|
}): Promise<Task[]>;
|
|
633
|
+
/** @deprecated Use `detail`. Removed in version 2. */
|
|
616
634
|
get(taskId: string): Promise<Task | null>;
|
|
635
|
+
active(projectId: string): Promise<ActiveTasks>;
|
|
636
|
+
completed(projectId: string, query?: CompletedTaskQuery): Promise<CompletedTaskPage>;
|
|
637
|
+
detail(projectId: string, taskId: string): Promise<TaskRead | null>;
|
|
617
638
|
create(request: CreateTaskRequest): Promise<Task>;
|
|
618
639
|
/**
|
|
619
640
|
* Opens the host's create-task dialog pre-filled, letting the user edit the
|
|
620
641
|
* prompt — including anything contributed at that injection point —
|
|
621
|
-
* before the task exists.
|
|
642
|
+
* before the task exists. Optional `worktreeSource` / `worktreeBranch` seed
|
|
643
|
+
* the environment controls the same way `title` and `sourceTicketUrl` seed
|
|
644
|
+
* their fields.
|
|
622
645
|
* Resolves null if they dismiss it.
|
|
623
646
|
*/
|
|
624
647
|
compose(request: ComposeTaskRequest): Promise<ComposeTaskResult | null>;
|
|
@@ -632,6 +655,13 @@ export interface TasksAPI {
|
|
|
632
655
|
/** Returns matching Agent Sessions newest first. */
|
|
633
656
|
listSessions(request: ListTaskSessionsRequest): Promise<AgentSession[]>;
|
|
634
657
|
}
|
|
658
|
+
export interface TasksAPI extends TaskOperationsAPI {
|
|
659
|
+
/**
|
|
660
|
+
* Subscribes to coalescible Task invalidations for one Project.
|
|
661
|
+
* Repeat the relevant bounded read instead of treating events as snapshots.
|
|
662
|
+
*/
|
|
663
|
+
onDidChange(projectId: string, handler: (event: TaskChangeEvent) => void): Disposable;
|
|
664
|
+
}
|
|
635
665
|
export interface ProjectsAPI {
|
|
636
666
|
list(): Promise<Project[]>;
|
|
637
667
|
get(projectId: string): Promise<Project | null>;
|
|
@@ -663,7 +693,7 @@ export interface OpenForgeCommonAPI {
|
|
|
663
693
|
getSnapshot(): OpenForgeContextSnapshot;
|
|
664
694
|
};
|
|
665
695
|
agentSessions: AgentSessionsAPI;
|
|
666
|
-
tasks:
|
|
696
|
+
tasks: TaskOperationsAPI;
|
|
667
697
|
projects: ProjectsAPI;
|
|
668
698
|
fs: FileSystemAPI;
|
|
669
699
|
shell: ShellAPI;
|
|
@@ -674,6 +704,7 @@ export interface OpenForgeCommonAPI {
|
|
|
674
704
|
projectConfig: KeyValueConfigAPI;
|
|
675
705
|
}
|
|
676
706
|
export interface FrontendOpenForgeAPI extends OpenForgeCommonAPI {
|
|
707
|
+
tasks: TasksAPI;
|
|
677
708
|
browserSurfaces: BrowserSurfacesAPI;
|
|
678
709
|
navigation: NavigationAPI;
|
|
679
710
|
views: FrontendViewRegistry;
|
|
@@ -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
|
@@ -179,7 +179,12 @@
|
|
|
179
179
|
{#if header}
|
|
180
180
|
{@render header()}
|
|
181
181
|
{/if}
|
|
182
|
-
<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>
|
|
183
188
|
</div>
|
|
184
189
|
{/if}
|
|
185
190
|
{@render children()}
|
|
@@ -23,17 +23,12 @@
|
|
|
23
23
|
class: className,
|
|
24
24
|
}: Props = $props()
|
|
25
25
|
|
|
26
|
-
function handleKeydown(event: KeyboardEvent): void {
|
|
27
|
-
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
28
|
-
event.preventDefault()
|
|
29
|
-
onActivate()
|
|
30
|
-
}
|
|
31
26
|
</script>
|
|
32
27
|
|
|
33
28
|
<button
|
|
34
29
|
type="button"
|
|
35
30
|
class={[
|
|
36
|
-
'relative mx-2 flex min-h-11 w-
|
|
31
|
+
'relative mx-2 flex min-h-11 w-[calc(100%_-_1rem)] items-center rounded-lg gap-3 py-2.5 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
|
|
37
32
|
collapsed ? 'justify-center px-0' : 'px-3',
|
|
38
33
|
active ? 'bg-primary/10 text-primary' : 'text-base-content/55 hover:bg-base-200 hover:text-base-content',
|
|
39
34
|
className,
|
|
@@ -42,7 +37,6 @@
|
|
|
42
37
|
aria-label={accessibleName}
|
|
43
38
|
aria-current={active ? 'page' : undefined}
|
|
44
39
|
onclick={onActivate}
|
|
45
|
-
onkeydown={handleKeydown}
|
|
46
40
|
>
|
|
47
41
|
{#if leading}
|
|
48
42
|
<span class="relative shrink-0">{@render leading()}</span>
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openforge-app/plugin-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "TypeScript SDK for building trusted OpenForge frontend and backend plugins.",
|
|
5
|
+
"homepage": "https://github.com/koenvg/openforge/blob/main/docs/plugin-authoring.md",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"openforge",
|
|
8
|
+
"plugin",
|
|
9
|
+
"sdk",
|
|
10
|
+
"svelte"
|
|
11
|
+
],
|
|
4
12
|
"license": "MIT",
|
|
5
13
|
"repository": {
|
|
6
14
|
"type": "git",
|
|
@@ -94,15 +102,16 @@
|
|
|
94
102
|
"access": "public"
|
|
95
103
|
},
|
|
96
104
|
"dependencies": {
|
|
97
|
-
"dompurify": "^3.4.
|
|
98
|
-
"marked": "^18.0.
|
|
105
|
+
"dompurify": "^3.4.14",
|
|
106
|
+
"marked": "^18.0.11",
|
|
107
|
+
"mermaid": "^11.17.2"
|
|
99
108
|
},
|
|
100
109
|
"peerDependencies": {
|
|
101
110
|
"svelte": "^5.0.0"
|
|
102
111
|
},
|
|
103
112
|
"devDependencies": {
|
|
104
|
-
"@types/node": "26.
|
|
105
|
-
"svelte": "5.56.
|
|
113
|
+
"@types/node": "26.3.0",
|
|
114
|
+
"svelte": "5.56.10",
|
|
106
115
|
"semver": "^7.8.5",
|
|
107
116
|
"typescript": "^7.0.2",
|
|
108
117
|
"vitest": "^4.1.11",
|