@templatical/core
Advanced tools
| import { Block, Template, TemplateContent, TemplateDefaults, TemplateSettings, TemplatesProvider, UiTheme, ViewportSize } from "@templatical/types"; | ||
| import { DeepReadonly, Ref } from "@vue/reactivity"; | ||
| //#region src/editor.d.ts | ||
| interface EditorState { | ||
| /** | ||
| * The template currently being edited, as the store last returned it — `null` | ||
| * until `create()` or `load()` resolves, and always `null` without a | ||
| * {@link TemplatesProvider}. | ||
| * | ||
| * Carries identity and name only for practical purposes: its `content` is the | ||
| * store's copy from the last round-trip, whereas `state.content` is what the | ||
| * user is editing. | ||
| */ | ||
| template: Template | null; | ||
| content: TemplateContent; | ||
| selectedBlockId: string | null; | ||
| viewport: ViewportSize; | ||
| darkMode: boolean; | ||
| previewMode: boolean; | ||
| isDirty: boolean; | ||
| /** True for the duration of a `save()` call. */ | ||
| isSaving: boolean; | ||
| /** True for the duration of a `create()` or `load()` call. */ | ||
| isLoading: boolean; | ||
| uiTheme: UiTheme; | ||
| } | ||
| interface UseEditorOptions { | ||
| content: TemplateContent; | ||
| defaultFontFamily?: string; | ||
| templateDefaults?: TemplateDefaults; | ||
| lockedBlocks?: Ref<Map<string, unknown>>; | ||
| /** | ||
| * Storage backend for the template's save/load lifecycle. Omit it and | ||
| * `create()` / `load()` / `save()` reject — the editor keeps working as a | ||
| * purely local editing surface. | ||
| */ | ||
| templates?: TemplatesProvider; | ||
| /** | ||
| * Called with any error a provider call rejects with, before the rejection is | ||
| * re-thrown to the caller. | ||
| */ | ||
| onError?: (error: Error) => void; | ||
| } | ||
| interface UseEditorReturn { | ||
| state: DeepReadonly<EditorState>; | ||
| content: Ref<TemplateContent>; | ||
| selectedBlock: Ref<Block | null>; | ||
| setContent: (content: TemplateContent, markDirty?: boolean) => void; | ||
| selectBlock: (blockId: string | null) => void; | ||
| setViewport: (viewport: ViewportSize) => void; | ||
| setDarkMode: (darkMode: boolean) => void; | ||
| setPreviewMode: (previewMode: boolean) => void; | ||
| setUiTheme: (theme: UiTheme) => void; | ||
| updateBlock: (blockId: string, updates: Partial<Block>) => void; | ||
| updateSettings: (updates: Partial<TemplateSettings>) => void; | ||
| addBlock: (block: Block, targetSectionId?: string, columnIndex?: number, index?: number) => void; | ||
| removeBlock: (blockId: string) => void; | ||
| moveBlock: (blockId: string, newIndex: number, targetSectionId?: string, columnIndex?: number) => void; | ||
| isBlockLocked: (blockId: string) => boolean; | ||
| markDirty: () => void; | ||
| findBlockLocation: (blockId: string) => { | ||
| targetSectionId?: string; | ||
| columnIndex?: number; | ||
| index: number; | ||
| } | null; | ||
| /** | ||
| * Rename the loaded template locally and mark the editor dirty. The new name | ||
| * reaches the store on the next `save()`, in the same patch as the content — | ||
| * a rename is an ordinary unsaved change, not a side channel. | ||
| * | ||
| * This method only stages it. The editor's inline rename field commits by | ||
| * calling `setName()` and then saving immediately, because a rename reads as a | ||
| * discrete action rather than an edit to be batched — so in the editor the | ||
| * "next `save()`" is usually the one it triggers itself. A headless caller | ||
| * decides its own moment. | ||
| * | ||
| * No-op when no template is loaded: there is nothing to name. | ||
| */ | ||
| setName: (name: string) => void; | ||
| /** | ||
| * Persist the current content as a new template. `input.content`, when given, | ||
| * replaces the editor's content first — so `create({ content })` both loads | ||
| * and stores in one step. | ||
| */ | ||
| create: (input?: { | ||
| name?: string; | ||
| content?: TemplateContent; | ||
| }) => Promise<Template>; | ||
| /** Fetch a template and make it the editor's content. */ | ||
| load: (templateId: string) => Promise<Template>; | ||
| /** Persist the loaded template's name + content as a patch. */ | ||
| save: () => Promise<Template>; | ||
| /** Whether a template has been created or loaded. */ | ||
| hasTemplate: () => boolean; | ||
| } | ||
| declare function useEditor(options: UseEditorOptions): UseEditorReturn; | ||
| //#endregion | ||
| export { useEditor as i, UseEditorOptions as n, UseEditorReturn as r, EditorState as t }; | ||
| //# sourceMappingURL=editor-BCujEIye.d.ts.map |
+196
-110
@@ -1,6 +0,24 @@ | ||
| import { r as UseEditorReturn$1 } from "../editor-BIIsaIoN.js"; | ||
| import { AiChatMessage, AiConfig, AuthConfig, AuthRequestOptions, Block, Collaborator, Comment, CommentEvent, CommentThread, CustomFont, EditorState, ExportResult, FontsConfig, HealthCheckResult, McpOperationPayload, MergeTag, PlanConfig, PlanFeatures, SavedBlock, SavedBlocksProvider, ScoringCategory, ScoringFinding, ScoringResult, SdkAuthConfig, Template, TemplateContent, TemplateDefaults, TemplateSettings, TemplateSnapshot, TestEmailConfig, TestEmailProvider, UiTheme, UserConfig, ViewportSize, WebSocketServerConfig } from "@templatical/types"; | ||
| import { ComputedRef, DeepReadonly, Ref, ref } from "vue"; | ||
| import { r as UseEditorReturn } from "../editor-BCujEIye.js"; | ||
| import { AiChatMessage, AiConfig, AuthConfig, AuthRequestOptions, Block, Collaborator, CommentResponse, CommentsProvider, CustomFont, ExportResult, FontsConfig, HealthCheckResult, McpOperationPayload, MergeTag, PlanConfig, PlanFeatures, RenderProvider, SavedBlock, SavedBlocksProvider, ScoringCategory, ScoringFinding, ScoringResult, SdkAuthConfig, Template, TemplateContent, TemplatePatch, TemplateVersionResponse, TemplatesProvider, TestEmailConfig, TestEmailProvider, UserConfig, VersionHistoryProvider, WebSocketServerConfig } from "@templatical/types"; | ||
| import { ComputedRef, Ref, ref } from "vue"; | ||
| import { Channel, PresenceChannel } from "pusher-js"; | ||
| //#region src/cloud/auth.d.ts | ||
| /** | ||
| * Whether a token-refresh failure is worth interrupting the user over. | ||
| * | ||
| * **Fatal** means the endpoint actively rejected the credentials — a `4xx`. No | ||
| * amount of retrying fixes that: every subsequent request will fail the same | ||
| * way, so the session really is over and the editor should say so. | ||
| * | ||
| * **Everything else is transient**: a network blip, a `5xx`, a timeout, a | ||
| * malformed response. Those resolve on their own, and blanking the editor over | ||
| * one interrupts someone mid-edit on a template that is very likely unsaved. | ||
| * `AuthManager` reports every failure to `onError` and re-throws regardless, so | ||
| * a caller that genuinely cannot proceed still finds out; this only decides | ||
| * whether the *overlay* goes up. | ||
| * | ||
| * `404` is treated as fatal deliberately: a refresh URL that does not exist is a | ||
| * misconfiguration, and retrying a typo forever is worse than saying so. | ||
| */ | ||
| declare function isFatalAuthError(error: unknown): boolean; | ||
| declare class AuthManager { | ||
@@ -47,9 +65,16 @@ private static readonly DEFAULT_BASE_URL; | ||
| private extractFirstValidationError; | ||
| createTemplate(content: TemplateContent): Promise<Template>; | ||
| createTemplate(content: TemplateContent, name?: string): Promise<Template>; | ||
| getTemplate(id: string): Promise<Template>; | ||
| updateTemplate(id: string, content: TemplateContent): Promise<Template>; | ||
| createSnapshot(templateId: string, content: TemplateContent): Promise<TemplateSnapshot>; | ||
| /** | ||
| * Apply a partial update. Takes a patch rather than bare content so a rename | ||
| * can travel without content and vice versa — the shape `TemplatePatch` | ||
| * defines, which is what `createCloudTemplatesProvider` forwards verbatim. Only | ||
| * the keys present are sent. | ||
| */ | ||
| updateTemplate(id: string, patch: TemplatePatch): Promise<Template>; | ||
| createVersion(templateId: string, content: TemplateContent, label?: string): Promise<TemplateVersionResponse>; | ||
| deleteTemplate(id: string): Promise<void>; | ||
| getSnapshots(templateId: string): Promise<TemplateSnapshot[]>; | ||
| restoreSnapshot(templateId: string, snapshotId: string): Promise<Template>; | ||
| getVersions(templateId: string): Promise<TemplateVersionResponse[]>; | ||
| getVersion(templateId: string, versionId: string): Promise<TemplateVersionResponse>; | ||
| restoreVersion(templateId: string, versionId: string): Promise<Template>; | ||
| exportTemplate(templateId: string, fontsPayload?: { | ||
@@ -69,3 +94,3 @@ customFonts: CustomFont[]; | ||
| private commentsUrl; | ||
| getComments(templateId: string): Promise<Comment[]>; | ||
| getComments(templateId: string): Promise<CommentResponse[]>; | ||
| createComment(templateId: string, data: { | ||
@@ -78,3 +103,3 @@ body: string; | ||
| user_signature: string; | ||
| }, headers?: Record<string, string>): Promise<Comment>; | ||
| }, headers?: Record<string, string>): Promise<CommentResponse>; | ||
| updateComment(templateId: string, commentId: string, data: { | ||
@@ -85,3 +110,3 @@ body: string; | ||
| user_signature: string; | ||
| }, headers?: Record<string, string>): Promise<Comment>; | ||
| }, headers?: Record<string, string>): Promise<CommentResponse>; | ||
| deleteComment(templateId: string, commentId: string, data: { | ||
@@ -96,3 +121,3 @@ user_id: string; | ||
| user_signature: string; | ||
| }, headers?: Record<string, string>): Promise<Comment>; | ||
| }, headers?: Record<string, string>): Promise<CommentResponse>; | ||
| fetchConfig(): Promise<PlanConfig>; | ||
@@ -126,6 +151,6 @@ listModules(search?: string, category?: string): Promise<SavedBlock[]>; | ||
| readonly "templates.sendTestEmail": "/api/v1/projects/{project}/tenants/{tenant}/templates/{template}/send-test-email"; | ||
| readonly "snapshots.index": "/api/v1/projects/{project}/tenants/{tenant}/templates/{template}/snapshots"; | ||
| readonly "snapshots.store": "/api/v1/projects/{project}/tenants/{tenant}/templates/{template}/snapshots"; | ||
| readonly "snapshots.show": "/api/v1/projects/{project}/tenants/{tenant}/templates/{template}/snapshots/{snapshot}"; | ||
| readonly "snapshots.restore": "/api/v1/projects/{project}/tenants/{tenant}/templates/{template}/snapshots/{snapshot}/restore"; | ||
| readonly "versions.index": "/api/v1/projects/{project}/tenants/{tenant}/templates/{template}/versions"; | ||
| readonly "versions.store": "/api/v1/projects/{project}/tenants/{tenant}/templates/{template}/versions"; | ||
| readonly "versions.show": "/api/v1/projects/{project}/tenants/{tenant}/templates/{template}/versions/{version}"; | ||
| readonly "versions.restore": "/api/v1/projects/{project}/tenants/{tenant}/templates/{template}/versions/{version}/restore"; | ||
| readonly "comments.index": "/api/v1/projects/{project}/tenants/{tenant}/templates/{template}/comments"; | ||
@@ -194,41 +219,2 @@ readonly "comments.store": "/api/v1/projects/{project}/tenants/{tenant}/templates/{template}/comments"; | ||
| //#endregion | ||
| //#region src/cloud/editor.d.ts | ||
| interface UseEditorOptions { | ||
| authManager: AuthManager; | ||
| defaultFontFamily?: string; | ||
| templateDefaults?: TemplateDefaults; | ||
| onError?: (error: Error) => void; | ||
| lockedBlocks?: Ref<Map<string, unknown>>; | ||
| } | ||
| interface UseEditorReturn { | ||
| state: DeepReadonly<EditorState>; | ||
| content: Ref<TemplateContent>; | ||
| selectedBlock: Ref<Block | null>; | ||
| setContent: (content: TemplateContent, markDirty?: boolean) => void; | ||
| selectBlock: (blockId: string | null) => void; | ||
| setViewport: (viewport: ViewportSize) => void; | ||
| setDarkMode: (darkMode: boolean) => void; | ||
| setPreviewMode: (previewMode: boolean) => void; | ||
| setUiTheme: (theme: UiTheme) => void; | ||
| updateBlock: (blockId: string, updates: Partial<Block>) => void; | ||
| updateSettings: (updates: Partial<TemplateSettings>) => void; | ||
| addBlock: (block: Block, targetSectionId?: string, columnIndex?: number, index?: number) => void; | ||
| removeBlock: (blockId: string) => void; | ||
| moveBlock: (blockId: string, newIndex: number, targetSectionId?: string, columnIndex?: number) => void; | ||
| savedBlockIds: Ref<Set<string>>; | ||
| isBlockLocked: (blockId: string) => boolean; | ||
| findBlockLocation: (blockId: string) => { | ||
| targetSectionId?: string; | ||
| columnIndex?: number; | ||
| index: number; | ||
| } | null; | ||
| create: (content?: TemplateContent) => Promise<Template>; | ||
| load: (templateId: string) => Promise<Template>; | ||
| save: () => Promise<Template>; | ||
| createSnapshot: () => Promise<void>; | ||
| hasTemplate: () => boolean; | ||
| markDirty: () => void; | ||
| } | ||
| declare function useEditor(options: UseEditorOptions): UseEditorReturn; | ||
| //#endregion | ||
| //#region src/cloud/mcp-operation-handler.d.ts | ||
@@ -327,42 +313,56 @@ declare function handleOperation(editor: UseEditorReturn, payload: McpOperationPayload): void; | ||
| //#endregion | ||
| //#region src/cloud/comments.d.ts | ||
| interface UseCommentsOptions { | ||
| //#region src/cloud/comments-provider.d.ts | ||
| /** | ||
| * The two methods this adapter needs from a Pusher channel, named structurally. | ||
| * | ||
| * Deliberately not `PresenceChannel`: `pusher-js` is an **optional** peer of this | ||
| * package, so a type imported from it would make `@templatical/editor` — which | ||
| * holds the channel ref for us — fail to typecheck without it installed. A | ||
| * `PresenceChannel` satisfies this shape, so nothing at the call site changes. | ||
| */ | ||
| interface RealtimeChannel { | ||
| bind(event: string, handler: (payload: any) => void): unknown; | ||
| unbind(event: string, handler?: (payload: any) => void): unknown; | ||
| } | ||
| interface CreateCloudCommentsProviderOptions { | ||
| authManager: AuthManager; | ||
| getTemplateId: () => string | null; | ||
| /** | ||
| * Cloud's presence channel for the open template, or `null` before it joins. | ||
| * | ||
| * A ref rather than a value because the channel arrives after the template | ||
| * loads and is replaced when another one is opened — {@link subscribe} watches | ||
| * it and rebinds, which is what keeps realtime optional from the editor's point | ||
| * of view: the contract only ever sees `subscribe`. | ||
| */ | ||
| channel: Ref<RealtimeChannel | null>; | ||
| /** | ||
| * The socket id to stamp on writes, so Cloud's backend can skip echoing them | ||
| * back to their author. Returns `null` before the socket connects. | ||
| */ | ||
| getSocketId?: () => string | null; | ||
| onComment?: (event: CommentEvent) => void; | ||
| onError?: (error: Error) => void; | ||
| isAuthReady?: Ref<boolean>; | ||
| hasCommentingFeature?: () => boolean; | ||
| } | ||
| interface UseCommentsReturn { | ||
| comments: Ref<CommentThread[]>; | ||
| isLoading: Ref<boolean>; | ||
| isSubmitting: Ref<boolean>; | ||
| isEnabled: ComputedRef<boolean>; | ||
| commentCountByBlock: ComputedRef<Map<string, number>>; | ||
| totalCount: ComputedRef<number>; | ||
| unresolvedCount: ComputedRef<number>; | ||
| loadComments: () => Promise<void>; | ||
| addComment: (body: string, blockId?: string, parentId?: string) => Promise<Comment | null>; | ||
| editComment: (commentId: string, body: string) => Promise<Comment | null>; | ||
| removeComment: (commentId: string) => Promise<boolean>; | ||
| toggleResolve: (commentId: string) => Promise<Comment | null>; | ||
| applyRemoteCreate: (comment: Comment) => void; | ||
| applyRemoteUpdate: (comment: Comment) => void; | ||
| applyRemoteDelete: (commentId: string, parentId: string | null) => void; | ||
| } | ||
| declare function useComments(options: UseCommentsOptions): UseCommentsReturn; | ||
| /** | ||
| * Cloud-backed {@link CommentsProvider} — the Templatical Cloud adapter for the | ||
| * same review contract consumers implement themselves. | ||
| * | ||
| * All four mutations are enabled: comment storage and its realtime fan-out are | ||
| * what the `commenting` plan feature pays for, so there is no Cloud tier that can | ||
| * read a thread but not reply to it. A consumer who wants a read-only review | ||
| * supplies their own provider with the mutations set to `false`. | ||
| * | ||
| * Two things about the write payloads are Cloud's alone and stay on this side of | ||
| * the seam: | ||
| * | ||
| * - **The author is signed.** `user_id` / `user_name` / `user_signature` come from | ||
| * the JWT, not from the editor's `user` config, so a browser cannot attribute a | ||
| * comment to someone else. The editor's `user` key still gates the feature and | ||
| * drives "you wrote this" in the UI; the two agree because `initCloud()` fills | ||
| * `user` from the same JWT. | ||
| * - **`X-Socket-ID`.** Cloud excludes the originating socket from the broadcast, | ||
| * which is why a local write and its echo can't double-post. Every implementation | ||
| * is nonetheless echo-safe (`upsert` replaces by id), so a backend without that | ||
| * header is fine too. | ||
| */ | ||
| declare function createCloudCommentsProvider(options: CreateCloudCommentsProviderOptions): CommentsProvider; | ||
| //#endregion | ||
| //#region src/cloud/comment-listener.d.ts | ||
| interface CommentBroadcastPayload { | ||
| action: "comment_created" | "comment_updated" | "comment_deleted" | "comment_resolved" | "comment_unresolved"; | ||
| comment: Comment; | ||
| } | ||
| interface UseCommentListenerOptions { | ||
| comments: UseCommentsReturn; | ||
| channel: Ref<PresenceChannel | null>; | ||
| } | ||
| declare function useCommentListener(options: UseCommentListenerOptions): void; | ||
| //#endregion | ||
| //#region src/cloud/collaboration.d.ts | ||
@@ -405,3 +405,3 @@ interface UseCollaborationOptions { | ||
| */ | ||
| declare function useCollaborationBroadcast(editor: UseEditorReturn$1, collaboration: BroadcastTarget): void; | ||
| declare function useCollaborationBroadcast(editor: Pick<UseEditorReturn, "addBlock" | "updateBlock" | "removeBlock" | "moveBlock" | "updateSettings" | "setContent">, collaboration: BroadcastTarget): void; | ||
| //#endregion | ||
@@ -444,18 +444,87 @@ //#region src/cloud/web-socket.d.ts | ||
| //#endregion | ||
| //#region src/cloud/snapshots.d.ts | ||
| interface UseSnapshotHistoryOptions { | ||
| //#region src/cloud/templates-provider.d.ts | ||
| /** | ||
| * Cloud-backed {@link TemplatesProvider} — the Templatical Cloud adapter for the | ||
| * same save/load contract consumers implement themselves. | ||
| * | ||
| * Auth (JWT via {@link AuthManager}) and project/tenant scoping live entirely on | ||
| * this side of the seam; the editor's header, Cmd+S, autosave and | ||
| * unsaved-changes guard never see them. | ||
| * | ||
| * All three methods are enabled: Cloud's template storage is what the plan pays | ||
| * for, so there is no tier in which a Cloud session can load but not save. A | ||
| * consumer who wants read-only supplies their own provider with `save: false`. | ||
| * | ||
| * **This is also where automatic versions are recorded.** The editor never | ||
| * creates one on its own — whoever implements `save` decides whether a save also | ||
| * records a version, which keeps throttling and retention with the side that | ||
| * pays for the storage. Cloud throttles to one automatic version per | ||
| * {@link AUTO_VERSION_INTERVAL_MS}, so an autosave firing every few seconds does | ||
| * not turn history into a keystroke log. This replaced an editor-side | ||
| * `createSnapshot()` on a timer, which put Cloud's retention policy in the | ||
| * editor. | ||
| * | ||
| * Note `initCloud()` **rejects** a consumer-supplied templates provider, unlike | ||
| * `savedBlocks` and `testEmail`. Those are inert — nothing keys off them. The | ||
| * template id is the join key for collaboration, version history, comments, AI | ||
| * rewrite, scoring and the server-side export, so an id Cloud never issued would | ||
| * degrade all six silently. | ||
| */ | ||
| declare function createCloudTemplatesProvider(authManager: AuthManager): TemplatesProvider; | ||
| //#endregion | ||
| //#region src/cloud/render-provider.d.ts | ||
| interface CreateCloudRenderProviderOptions { | ||
| authManager: AuthManager; | ||
| templateId: string; | ||
| onRestore?: (template: Template) => void; | ||
| onError?: (error: Error) => void; | ||
| /** The loaded template's id, or `null` before one exists. */ | ||
| getTemplateId: () => string | null; | ||
| /** Persist the canvas so the endpoint renders what the user is looking at. */ | ||
| save: () => Promise<Template>; | ||
| } | ||
| interface UseSnapshotHistoryReturn { | ||
| snapshots: Ref<TemplateSnapshot[]>; | ||
| isLoading: Ref<boolean>; | ||
| isRestoring: Ref<boolean>; | ||
| loadSnapshots: () => Promise<void>; | ||
| restoreSnapshot: (snapshotId: string) => Promise<Template>; | ||
| } | ||
| declare function useSnapshotHistory(options: UseSnapshotHistoryOptions): UseSnapshotHistoryReturn; | ||
| /** | ||
| * Templatical Cloud's renderer, shaped as a {@link RenderProvider} so it plugs | ||
| * into the same editor seam a consumer's own backend would. | ||
| * | ||
| * **Why Cloud renders server-side at all**, rather than running the bundled | ||
| * renderer like an OSS consumer: its output is a deliberate *superset* that a | ||
| * browser cannot produce. A countdown block resolves to a URL serving a live, | ||
| * on-demand animated GIF; a video block gets a composited play button. Both are | ||
| * injected into the published renderer through `blockRenderers` in Cloud's Node | ||
| * sidecar, so the delta is two functions and parity on the other twelve block | ||
| * types holds by construction. | ||
| * | ||
| * Two consequences of the endpoint rendering the **stored** template: | ||
| * | ||
| * - **Every render saves first.** The same trade the test-email adapter makes, for | ||
| * the same reason: exporting a stale version of what is on screen is worse than | ||
| * a write the caller did not ask for. It also means `toMjml()` needs a template | ||
| * to exist — a Cloud session that never created one gets a clear rejection | ||
| * rather than an export of nothing. | ||
| * - **`payload.content` is ignored**, along with the custom-block `renderedHtml` | ||
| * the editor pre-rendered into it. Cloud's `save()` persists that same content | ||
| * immediately before, so the server reads it from storage rather than trusting an | ||
| * echo. `payload.fonts` *is* read, because it is the only place the editor's | ||
| * effective font set is expressed. Don't "fix" the content branch. | ||
| * | ||
| * `compileMjml` is deliberately absent: `toMjml` and `toHtml` are both whole- | ||
| * pipeline calls here, so there is no MJML the editor would hand back for | ||
| * compiling. | ||
| */ | ||
| declare function createCloudRenderProvider(options: CreateCloudRenderProviderOptions): RenderProvider; | ||
| //#endregion | ||
| //#region src/cloud/version-history-provider.d.ts | ||
| /** | ||
| * Cloud-backed {@link VersionHistoryProvider} — the Templatical Cloud adapter for | ||
| * the same contract consumers implement themselves. | ||
| * | ||
| * Both mutations are enabled: version storage is what the plan pays for, so | ||
| * there is no Cloud tier that can list history but not restore it. A consumer | ||
| * who wants read-only history supplies their own provider with `restore: false`. | ||
| * | ||
| * **Automatic versions are not created here.** They are recorded by | ||
| * `createCloudTemplatesProvider`'s `save`, because the contract puts that | ||
| * decision on whoever implements `save` — the side that knows the storage cost. | ||
| * `create` here is for a version a person asked for. | ||
| */ | ||
| declare function createCloudVersionHistoryProvider(authManager: AuthManager): VersionHistoryProvider; | ||
| //#endregion | ||
| //#region src/cloud/test-email.d.ts | ||
@@ -525,11 +594,28 @@ /** | ||
| //#region src/cloud/export.d.ts | ||
| /** The fonts half of an export request, as Cloud's endpoint expects it. */ | ||
| interface ExportFontsPayload { | ||
| customFonts: CustomFont[]; | ||
| defaultFallback: string; | ||
| } | ||
| interface UseExportOptions { | ||
| authManager: AuthManager; | ||
| getFontsConfig?: () => FontsConfig | undefined; | ||
| canUseCustomFonts?: () => boolean; | ||
| } | ||
| interface UseExportReturn { | ||
| exportHtml: (templateId: string) => Promise<ExportResult>; | ||
| getMjmlSource: (templateId: string) => Promise<string>; | ||
| exportHtml: (templateId: string, fonts: ExportFontsPayload) => Promise<ExportResult>; | ||
| getMjmlSource: (templateId: string, fonts: ExportFontsPayload) => Promise<string>; | ||
| } | ||
| /** | ||
| * Flatten a {@link FontsConfig} into the export payload. | ||
| * | ||
| * Unconditional: gating fonts by entitlement meters no resource Cloud buys, and | ||
| * would only make the paid tier render fewer fonts than the free editor. | ||
| */ | ||
| declare function resolveExportFonts(fonts: FontsConfig | undefined): ExportFontsPayload; | ||
| /** | ||
| * Cloud's server-side export endpoint, as a plain API wrapper. | ||
| * | ||
| * Both calls render from the **stored** template, so callers save first when the | ||
| * canvas may have moved on — see `createCloudRenderProvider`, which is where that | ||
| * policy lives. | ||
| */ | ||
| declare function useExport(options: UseExportOptions): UseExportReturn; | ||
@@ -566,3 +652,3 @@ //#endregion | ||
| //#endregion | ||
| export { API_ROUTES, ApiClient, type AuthConfig, AuthManager, type AuthRequestOptions, type CommentBroadcastPayload, type CreateCloudTestEmailProviderOptions, type DesignReferenceInput, type PresenceMember, type SdkAuthConfig, type TestEmailConfig, type UseAiChatOptions, type UseAiChatReturn, type UseAiConfigReturn, type UseAiRewriteOptions, type UseAiRewriteReturn, type UseCollaborationOptions, type UseCollaborationReturn, type UseCommentListenerOptions, type UseCommentsOptions, type UseCommentsReturn, type UseDesignReferenceOptions, type UseDesignReferenceReturn, type UseEditorOptions, type UseEditorReturn, type UseExportOptions, type UseExportReturn, type UseMcpListenerOptions, type UsePlanConfigOptions, type UsePlanConfigReturn, type UseSnapshotHistoryOptions, type UseSnapshotHistoryReturn, type UseTemplateScoringOptions, type UseTemplateScoringReturn, type UseTestEmailOptions, type UseTestEmailReturn, type UseWebSocketOptions, type UseWebSocketReturn, type UserConfig, WebSocketClient, type WebSocketClientOptions, type WebSocketConfig, buildUrl, createCloudSavedBlocksProvider, createCloudTestEmailProvider, createSdkAuthManager, handleOperation, performHealthCheck, resolveWebSocketConfig, useAiChat, useAiConfig, useAiRewrite, useCollaboration, useCollaborationBroadcast, useCommentListener, useComments, useDesignReference, useEditor, useExport, useMcpListener, usePlanConfig, useSnapshotHistory, useTemplateScoring, useTestEmail, useWebSocket }; | ||
| export { API_ROUTES, ApiClient, type AuthConfig, AuthManager, type AuthRequestOptions, type CreateCloudCommentsProviderOptions, type CreateCloudRenderProviderOptions, type CreateCloudTestEmailProviderOptions, type DesignReferenceInput, type ExportFontsPayload, type PresenceMember, type RealtimeChannel, type SdkAuthConfig, type TestEmailConfig, type UseAiChatOptions, type UseAiChatReturn, type UseAiConfigReturn, type UseAiRewriteOptions, type UseAiRewriteReturn, type UseCollaborationOptions, type UseCollaborationReturn, type UseDesignReferenceOptions, type UseDesignReferenceReturn, type UseExportOptions, type UseExportReturn, type UseMcpListenerOptions, type UsePlanConfigOptions, type UsePlanConfigReturn, type UseTemplateScoringOptions, type UseTemplateScoringReturn, type UseTestEmailOptions, type UseTestEmailReturn, type UseWebSocketOptions, type UseWebSocketReturn, type UserConfig, WebSocketClient, type WebSocketClientOptions, type WebSocketConfig, buildUrl, createCloudCommentsProvider, createCloudRenderProvider, createCloudSavedBlocksProvider, createCloudTemplatesProvider, createCloudTestEmailProvider, createCloudVersionHistoryProvider, createSdkAuthManager, handleOperation, isFatalAuthError, performHealthCheck, resolveExportFonts, resolveWebSocketConfig, useAiChat, useAiConfig, useAiRewrite, useCollaboration, useCollaborationBroadcast, useDesignReference, useExport, useMcpListener, usePlanConfig, useTemplateScoring, useTestEmail, useWebSocket }; | ||
| //# sourceMappingURL=index.d.ts.map |
+209
-5
@@ -1,3 +0,3 @@ | ||
| import { i as useEditor, n as UseEditorOptions, r as UseEditorReturn, t as EditorState } from "./editor-BIIsaIoN.js"; | ||
| import { Block, BlockDefaults, BlockType, CustomBlock, CustomBlockDefinition, SavedBlock, SavedBlockPatch, SavedBlocksListParams, SavedBlocksProvider, TemplateContent } from "@templatical/types"; | ||
| import { i as useEditor, n as UseEditorOptions, r as UseEditorReturn, t as EditorState } from "./editor-BCujEIye.js"; | ||
| import { Block, BlockDefaults, BlockType, Comment, CommentAuthor, CommentEvent, CommentInput, CommentPatch, CommentsListParams, CommentsProvider, CustomBlock, CustomBlockDefinition, SavedBlock, SavedBlockPatch, SavedBlocksListParams, SavedBlocksProvider, Template, TemplateContent, TemplateSettings, TemplateVersion, VersionHistoryListParams, VersionHistoryProvider } from "@templatical/types"; | ||
| import { ComputedRef, Ref } from "@vue/reactivity"; | ||
@@ -63,2 +63,17 @@ import { ComputedRef as ComputedRef$1, Ref as Ref$1 } from "vue"; | ||
| } | ||
| /** | ||
| * Trailing debounce, in ms, measured from the *last* content mutation. | ||
| * | ||
| * Typing is not debounced upstream — TipTap's `onUpdate` calls `updateBlock` per | ||
| * keystroke — so this is the only thing between a keypress and a whole-document | ||
| * write. 1000 was too eager: ordinary prose pauses for a second constantly | ||
| * (word choice, re-reading, reaching for the mouse), so a single paragraph could | ||
| * produce dozens of full-content saves. 2000 roughly halves that while still | ||
| * landing well before a user wonders whether their work was kept. | ||
| * | ||
| * **The single default for both entry points.** `initCloud()` used to carry its | ||
| * own copy at 5000 in the editor package; two constants for one setting drifted | ||
| * silently and nothing linked them. Cloud imports this one now. | ||
| */ | ||
| declare const DEFAULT_AUTO_SAVE_DEBOUNCE_MS = 2000; | ||
| declare function useAutoSave(options: UseAutoSaveOptions): UseAutoSaveReturn; | ||
@@ -73,3 +88,14 @@ //#endregion | ||
| } | ||
| declare function useConditionPreview(editor: UseEditorReturn): UseConditionPreviewReturn; | ||
| /** | ||
| * The slice of an editor this needs — deliberately structural, so both the OSS | ||
| * and the Cloud `useEditor` satisfy it without either having to grow toward the | ||
| * other. | ||
| */ | ||
| interface ConditionPreviewEditor { | ||
| state: { | ||
| readonly selectedBlockId: string | null; | ||
| }; | ||
| selectBlock: (blockId: string | null) => void; | ||
| } | ||
| declare function useConditionPreview(editor: ConditionPreviewEditor): UseConditionPreviewReturn; | ||
| //#endregion | ||
@@ -91,2 +117,15 @@ //#region src/data-source-fetch.d.ts | ||
| /** | ||
| * The mutable slice of an editor this wraps — structural, so the OSS and Cloud | ||
| * `useEditor` returns both satisfy it without either having to grow toward the | ||
| * other. Not `Readonly`: the whole point is to replace these members in place. | ||
| */ | ||
| interface HistoryInterceptorEditor { | ||
| addBlock: (block: Block, targetSectionId?: string, columnIndex?: number, index?: number) => void; | ||
| removeBlock: (blockId: string) => void; | ||
| moveBlock: (blockId: string, newIndex: number, targetSectionId?: string, columnIndex?: number) => void; | ||
| updateBlock: (blockId: string, updates: Partial<Block>) => void; | ||
| updateSettings: (updates: Partial<TemplateSettings>) => void; | ||
| isBlockLocked: (blockId: string) => boolean; | ||
| } | ||
| /** | ||
| * Wraps editor mutation methods to record history snapshots before each | ||
@@ -98,3 +137,3 @@ * operation. Mutates the editor object in place. | ||
| */ | ||
| declare function useHistoryInterceptor(editor: UseEditorReturn, history: UseHistoryReturn): void; | ||
| declare function useHistoryInterceptor(editor: HistoryInterceptorEditor, history: UseHistoryReturn): void; | ||
| //#endregion | ||
@@ -191,3 +230,168 @@ //#region src/saved-blocks.d.ts | ||
| //#endregion | ||
| export { type EditorState, type LocalStorageSavedBlocksOptions, type UseAutoSaveOptions, type UseAutoSaveReturn, type UseBlockActionsOptions, type UseBlockActionsReturn, type UseConditionPreviewReturn, type UseEditorOptions, type UseEditorReturn, type UseHistoryOptions, type UseHistoryReturn, type UseSavedBlocksOptions, type UseSavedBlocksReturn, createLocalStorageSavedBlocksProvider, useAutoSave, useBlockActions, useConditionPreview, useDataSourceFetch, useEditor, useHistory, useHistoryInterceptor, useSavedBlocks }; | ||
| //#region src/version-history.d.ts | ||
| interface UseVersionHistoryOptions { | ||
| /** | ||
| * Storage backend. Supplied by the consumer via `init({ versionHistory })`, | ||
| * or by Cloud's own adapter — this composable is transport-agnostic and never | ||
| * talks to a network itself. | ||
| */ | ||
| provider: VersionHistoryProvider; | ||
| /** | ||
| * Which template's history this is. A getter rather than a value because a | ||
| * session can outlive the template: `initCloud()` constructs the feature at | ||
| * setup and only learns the id once `create()` / `load()` resolves. | ||
| */ | ||
| getTemplateId: () => string | null; | ||
| onError?: (error: Error) => void; | ||
| } | ||
| interface UseVersionHistoryReturn { | ||
| /** The provider's list, in the provider's order. Never re-sorted. */ | ||
| versions: Ref$1<TemplateVersion[]>; | ||
| isLoading: Ref$1<boolean>; | ||
| isRestoring: Ref$1<boolean>; | ||
| /** | ||
| * Cursor for the page after the one currently held, or `undefined` when the | ||
| * provider signalled there is no more. The editor loads one page and ignores | ||
| * this; it is here so a headless caller can page without reaching past the | ||
| * composable. | ||
| */ | ||
| nextCursor: Ref$1<string | undefined>; | ||
| /** Whether the provider supplied each mutation at all. */ | ||
| canCreate: ComputedRef$1<boolean>; | ||
| canRestore: ComputedRef$1<boolean>; | ||
| /** Re-read the list. The editor calls this bare; `params` is for headless callers. */ | ||
| load: (params?: VersionHistoryListParams) => Promise<void>; | ||
| /** | ||
| * The version's content **if it is already in hand** — the provider's | ||
| * `content` hint, or a previous `get` this composable cached. `null` means a | ||
| * round-trip is required. | ||
| * | ||
| * Callers that must not block (scrubbing through history swaps the canvas in | ||
| * the same tick) check this first and only await {@link resolveContent} when | ||
| * it comes back null. | ||
| */ | ||
| peekContent: (version: TemplateVersion) => TemplateContent | null; | ||
| /** | ||
| * `version.content ?? await provider.get(...)`, cached per version id, so a | ||
| * lazily-loaded version costs one round-trip on its first visit and nothing | ||
| * afterwards. | ||
| */ | ||
| resolveContent: (version: TemplateVersion) => Promise<TemplateContent>; | ||
| /** Rejects with an {@link SdkError} when the provider disabled `create`. */ | ||
| create: (content: TemplateContent, meta?: { | ||
| label?: string; | ||
| }) => Promise<TemplateVersion>; | ||
| /** Rejects with an {@link SdkError} when the provider disabled `restore`. */ | ||
| restore: (versionId: string) => Promise<Template>; | ||
| } | ||
| /** | ||
| * Reactive state over a {@link VersionHistoryProvider}. | ||
| * | ||
| * Owns the list, the loading flags and the per-version content cache that keeps | ||
| * scrubbing synchronous. Errors are reported through `onError` and re-thrown, | ||
| * leaving the list untouched on failure. | ||
| * | ||
| * It deliberately does **not** create versions of its own accord. The editor | ||
| * never records a version on a save — whoever implements `TemplatesProvider.save` | ||
| * decides that, because they are the side that pays for the storage. | ||
| */ | ||
| declare function useVersionHistory(options: UseVersionHistoryOptions): UseVersionHistoryReturn; | ||
| //#endregion | ||
| //#region src/comments.d.ts | ||
| interface UseCommentsOptions { | ||
| /** | ||
| * Storage backend. Supplied by the consumer via `init({ comments })`, or by | ||
| * Cloud's own adapter — this composable is transport-agnostic and never talks | ||
| * to a network itself. | ||
| */ | ||
| provider: CommentsProvider; | ||
| /** | ||
| * Which template's conversation this is. A getter rather than a value because a | ||
| * session can outlive the template: `initCloud()` constructs the feature at | ||
| * setup and only learns the id once `create()` / `load()` resolves. | ||
| */ | ||
| getTemplateId: () => string | null; | ||
| /** | ||
| * Who is commenting, read at call time. `null` means the editor has no | ||
| * identity, and every mutation refuses — an unattributable comment is worse | ||
| * than no comment feature. | ||
| */ | ||
| getUser: () => CommentAuthor | null; | ||
| /** Fired for every change this composable applied, local or remote. */ | ||
| onComment?: (event: CommentEvent) => void; | ||
| onError?: (error: Error) => void; | ||
| } | ||
| interface UseCommentsReturn { | ||
| /** Thread roots in the provider's order. Never re-sorted. */ | ||
| comments: Ref$1<Comment[]>; | ||
| isLoading: Ref$1<boolean>; | ||
| isSubmitting: Ref$1<boolean>; | ||
| /** Whether the provider supplied each mutation at all. */ | ||
| canCreate: ComputedRef$1<boolean>; | ||
| canUpdate: ComputedRef$1<boolean>; | ||
| canDelete: ComputedRef$1<boolean>; | ||
| canResolve: ComputedRef$1<boolean>; | ||
| /** Roots plus replies. */ | ||
| totalCount: ComputedRef$1<number>; | ||
| /** Unresolved roots — the badge on the trigger. */ | ||
| unresolvedCount: ComputedRef$1<number>; | ||
| /** Comments per anchored block id, roots plus their replies. */ | ||
| commentCountByBlock: ComputedRef$1<Map<string, number>>; | ||
| /** Re-read the list. The editor calls this bare; `params` is for headless callers. */ | ||
| load: (params?: CommentsListParams) => Promise<void>; | ||
| /** Rejects with an {@link SdkError} when `create` is disabled or there is no user. */ | ||
| create: (input: CommentInput) => Promise<Comment>; | ||
| /** Rejects with an {@link SdkError} when `update` is disabled or there is no user. */ | ||
| update: (commentId: string, patch: CommentPatch) => Promise<Comment>; | ||
| /** Rejects with an {@link SdkError} when `delete` is disabled or there is no user. */ | ||
| remove: (commentId: string) => Promise<void>; | ||
| /** Rejects with an {@link SdkError} when `setResolved` is disabled or there is no user. */ | ||
| setResolved: (commentId: string, resolved: boolean) => Promise<Comment>; | ||
| /** Look one up by id, root or reply. `null` when it isn't loaded. */ | ||
| find: (commentId: string) => Comment | null; | ||
| /** Whether this comment was written by the current user. */ | ||
| isOwn: (comment: Comment) => boolean; | ||
| /** | ||
| * Apply a change that arrived from {@link CommentsProvider.subscribe}. Public | ||
| * so a consumer driving this headlessly can push their own transport into it. | ||
| */ | ||
| applyRemoteCreate: (comment: Comment) => void; | ||
| applyRemoteUpdate: (comment: Comment) => void; | ||
| applyRemoteDelete: (commentId: string, parentId?: string | null) => void; | ||
| } | ||
| /** | ||
| * Reactive state over a {@link CommentsProvider}. | ||
| * | ||
| * Owns the thread list, the loading flags, the local mutations that keep the list | ||
| * consistent, and the derived counts the chrome renders. Errors are reported | ||
| * through `onError` and **re-thrown**, leaving the list untouched on failure — | ||
| * the same discipline as `useSavedBlocks` and `useVersionHistory`. | ||
| * | ||
| * Mutations **reject** when the provider withheld them rather than resolving to | ||
| * `null`: a resolved promise reads as "saved" to whoever awaited it. Ask through | ||
| * `canCreate` / `canUpdate` / `canDelete` / `canResolve` first — which is what the | ||
| * editor's own UI does, hiding each action rather than disabling it. | ||
| */ | ||
| declare function useComments(options: UseCommentsOptions): UseCommentsReturn; | ||
| interface UseCommentListenerOptions { | ||
| comments: UseCommentsReturn; | ||
| provider: CommentsProvider; | ||
| getTemplateId: () => string | null; | ||
| } | ||
| /** | ||
| * Wire {@link CommentsProvider.subscribe} into the three `applyRemote*` paths. | ||
| * | ||
| * A no-op when the provider has no `subscribe` — realtime is optional, and | ||
| * comments without it work exactly the same except that a colleague's comment | ||
| * only appears on the next read. Nothing here knows about a transport: Cloud's | ||
| * Pusher channel lives inside `createCloudCommentsProvider`, and an SSE or | ||
| * long-poll implementation is the same three lines on the consumer's side. | ||
| * | ||
| * Re-subscribes when the template id changes, and unsubscribes on scope dispose — | ||
| * so a session that loads a second template does not keep receiving the first | ||
| * one's comments. | ||
| */ | ||
| declare function useCommentListener(options: UseCommentListenerOptions): void; | ||
| //#endregion | ||
| export { DEFAULT_AUTO_SAVE_DEBOUNCE_MS, type EditorState, type LocalStorageSavedBlocksOptions, type UseAutoSaveOptions, type UseAutoSaveReturn, type UseBlockActionsOptions, type UseBlockActionsReturn, type UseCommentListenerOptions, type UseCommentsOptions, type UseCommentsReturn, type UseConditionPreviewReturn, type UseEditorOptions, type UseEditorReturn, type UseHistoryOptions, type UseHistoryReturn, type UseSavedBlocksOptions, type UseSavedBlocksReturn, type UseVersionHistoryOptions, type UseVersionHistoryReturn, createLocalStorageSavedBlocksProvider, useAutoSave, useBlockActions, useCommentListener, useComments, useConditionPreview, useDataSourceFetch, useEditor, useHistory, useHistoryInterceptor, useSavedBlocks, useVersionHistory }; | ||
| //# sourceMappingURL=index.d.ts.map |
+524
-12
| import { SdkError, createBlock, createDefaultTemplateContent, generateId, safeClone } from "@templatical/types"; | ||
| import { computed, reactive, readonly, ref, watch } from "@vue/reactivity"; | ||
| import { computed as computed$1, ref as ref$1 } from "vue"; | ||
| import { computed as computed$1, onScopeDispose, ref as ref$1, watch as watch$1 } from "vue"; | ||
| //#region src/editor.ts | ||
@@ -12,2 +12,3 @@ function getColumnCount(layout) { | ||
| const state = reactive({ | ||
| template: null, | ||
| content: options.content ?? createDefaultTemplateContent(options.defaultFontFamily, options.templateDefaults), | ||
@@ -19,4 +20,17 @@ selectedBlockId: null, | ||
| isDirty: false, | ||
| isSaving: false, | ||
| isLoading: false, | ||
| uiTheme: "auto" | ||
| }); | ||
| /** | ||
| * Bumped by every content mutation. `save()` captures it before awaiting the | ||
| * provider and only clears `isDirty` if it is unchanged afterwards, so an edit | ||
| * made while a save is in flight is not reported as persisted — which would | ||
| * also make autosave skip it, since that decides dirtiness at debounce time. | ||
| */ | ||
| let revision = 0; | ||
| function touch() { | ||
| state.isDirty = true; | ||
| revision++; | ||
| } | ||
| const content = computed({ | ||
@@ -26,3 +40,3 @@ get: () => state.content, | ||
| state.content = value; | ||
| state.isDirty = true; | ||
| touch(); | ||
| } | ||
@@ -79,3 +93,3 @@ }); | ||
| state.content = newContent; | ||
| if (markDirty) state.isDirty = true; | ||
| if (markDirty) touch(); | ||
| } | ||
@@ -104,3 +118,3 @@ function selectBlock(blockId) { | ||
| Object.assign(block, updates); | ||
| state.isDirty = true; | ||
| touch(); | ||
| } | ||
@@ -113,3 +127,3 @@ } | ||
| }; | ||
| state.isDirty = true; | ||
| touch(); | ||
| } | ||
@@ -130,3 +144,3 @@ function addBlock(block, targetSectionId, columnIndex = 0, index) { | ||
| else state.content.blocks.push(block); | ||
| state.isDirty = true; | ||
| touch(); | ||
| } | ||
@@ -145,3 +159,3 @@ function removeBlock(blockId) { | ||
| } | ||
| state.isDirty = true; | ||
| touch(); | ||
| } | ||
@@ -168,7 +182,102 @@ } | ||
| targetArray.splice(newIndex, 0, block); | ||
| state.isDirty = true; | ||
| touch(); | ||
| } | ||
| function markDirty() { | ||
| state.isDirty = true; | ||
| touch(); | ||
| } | ||
| /** | ||
| * A refused call throws rather than resolving, because a resolved promise | ||
| * would read as "saved" to whoever awaited it. | ||
| * | ||
| * The message names the provider key the consumer set, not an internal | ||
| * capability flag: `EditorCapabilities` is type-only and its injection key is | ||
| * unexported, so neither a core nor an editor consumer can read one. Note the | ||
| * editor hides its own save controls when `save` is withheld but has no | ||
| * create affordance to hide at all, so `create()` is reachable by definition. | ||
| */ | ||
| function refuse(action, reason) { | ||
| throw new SdkError(`[Templatical] Templates: ${action} ${reason}`); | ||
| } | ||
| function requireProvider(action) { | ||
| const { templates } = options; | ||
| if (!templates) refuse(action, "needs a templates provider. Pass one as `init({ templates })` to enable saving and loading."); | ||
| return templates; | ||
| } | ||
| function setName(name) { | ||
| if (!state.template) return; | ||
| state.template = { | ||
| ...state.template, | ||
| name | ||
| }; | ||
| touch(); | ||
| } | ||
| async function create(input) { | ||
| const { create: providerCreate } = requireProvider("create()"); | ||
| if (typeof providerCreate !== "function") refuse("create()", "is disabled by the provider — its `create` is `false`."); | ||
| state.isLoading = true; | ||
| try { | ||
| if (input?.content) state.content = input.content; | ||
| const revisionAtRequest = revision; | ||
| const template = await providerCreate(input?.name !== void 0 ? { | ||
| name: input.name, | ||
| content: state.content | ||
| } : { content: state.content }); | ||
| state.template = template; | ||
| if (revision === revisionAtRequest) state.isDirty = false; | ||
| return template; | ||
| } catch (error) { | ||
| options.onError?.(error); | ||
| throw error; | ||
| } finally { | ||
| state.isLoading = false; | ||
| } | ||
| } | ||
| async function load(templateId) { | ||
| const provider = requireProvider("load()"); | ||
| state.isLoading = true; | ||
| try { | ||
| const template = await provider.load(templateId); | ||
| state.template = template; | ||
| state.content = template.content; | ||
| state.isDirty = false; | ||
| return template; | ||
| } catch (error) { | ||
| options.onError?.(error); | ||
| throw error; | ||
| } finally { | ||
| state.isLoading = false; | ||
| } | ||
| } | ||
| async function save() { | ||
| const { save: providerSave } = requireProvider("save()"); | ||
| if (typeof providerSave !== "function") refuse("save()", "is disabled by the provider — its `save` is `false`."); | ||
| const current = state.template; | ||
| if (!current) refuse("save()", "has no template loaded. Call create() or load() first."); | ||
| const patch = current.name !== void 0 ? { | ||
| name: current.name, | ||
| content: state.content | ||
| } : { content: state.content }; | ||
| state.isSaving = true; | ||
| try { | ||
| const revisionAtRequest = revision; | ||
| const template = await providerSave(current.id, patch); | ||
| if (state.template?.id === current.id) { | ||
| const localName = state.template.name; | ||
| state.template = localName !== current.name ? { | ||
| ...template, | ||
| name: localName | ||
| } : template; | ||
| } | ||
| if (revision === revisionAtRequest) state.isDirty = false; | ||
| return template; | ||
| } catch (error) { | ||
| options.onError?.(error); | ||
| throw error; | ||
| } finally { | ||
| state.isSaving = false; | ||
| } | ||
| } | ||
| function hasTemplate() { | ||
| return state.template?.id !== void 0; | ||
| } | ||
| return { | ||
@@ -191,3 +300,8 @@ state: readonly(state), | ||
| markDirty, | ||
| findBlockLocation | ||
| findBlockLocation, | ||
| setName, | ||
| create, | ||
| load, | ||
| save, | ||
| hasTemplate | ||
| }; | ||
@@ -357,4 +471,19 @@ } | ||
| //#region src/auto-save.ts | ||
| /** | ||
| * Trailing debounce, in ms, measured from the *last* content mutation. | ||
| * | ||
| * Typing is not debounced upstream — TipTap's `onUpdate` calls `updateBlock` per | ||
| * keystroke — so this is the only thing between a keypress and a whole-document | ||
| * write. 1000 was too eager: ordinary prose pauses for a second constantly | ||
| * (word choice, re-reading, reaching for the mouse), so a single paragraph could | ||
| * produce dozens of full-content saves. 2000 roughly halves that while still | ||
| * landing well before a user wonders whether their work was kept. | ||
| * | ||
| * **The single default for both entry points.** `initCloud()` used to carry its | ||
| * own copy at 5000 in the editor package; two constants for one setting drifted | ||
| * silently and nothing linked them. Cloud imports this one now. | ||
| */ | ||
| const DEFAULT_AUTO_SAVE_DEBOUNCE_MS = 2e3; | ||
| function useAutoSave(options) { | ||
| const { content, isDirty, onChange, debounce = 1e3, enabled = true } = options; | ||
| const { content, isDirty, onChange, debounce = DEFAULT_AUTO_SAVE_DEBOUNCE_MS, enabled = true } = options; | ||
| let timeoutId = null; | ||
@@ -699,4 +828,387 @@ let paused = false; | ||
| //#endregion | ||
| export { createLocalStorageSavedBlocksProvider, useAutoSave, useBlockActions, useConditionPreview, useDataSourceFetch, useEditor, useHistory, useHistoryInterceptor, useSavedBlocks }; | ||
| //#region src/version-history.ts | ||
| /** | ||
| * Reactive state over a {@link VersionHistoryProvider}. | ||
| * | ||
| * Owns the list, the loading flags and the per-version content cache that keeps | ||
| * scrubbing synchronous. Errors are reported through `onError` and re-thrown, | ||
| * leaving the list untouched on failure. | ||
| * | ||
| * It deliberately does **not** create versions of its own accord. The editor | ||
| * never records a version on a save — whoever implements `TemplatesProvider.save` | ||
| * decides that, because they are the side that pays for the storage. | ||
| */ | ||
| function useVersionHistory(options) { | ||
| const { provider, getTemplateId } = options; | ||
| const versions = ref$1([]); | ||
| const isLoading = ref$1(false); | ||
| const nextCursor = ref$1(void 0); | ||
| const isRestoring = ref$1(false); | ||
| /** | ||
| * Content fetched through `get`, keyed by version id. A version that carried | ||
| * a `content` hint never lands here — the hint is read straight off the entry, | ||
| * so a provider that eager-loads pays no second copy. | ||
| */ | ||
| const fetched = /* @__PURE__ */ new Map(); | ||
| const canCreate = computed$1(() => typeof provider.create === "function"); | ||
| const canRestore = computed$1(() => typeof provider.restore === "function"); | ||
| /** | ||
| * The UI hides disabled actions and renders nothing before a template exists, | ||
| * so reaching one of these means a programmatic caller went around it. Fail | ||
| * loudly rather than silently no-op: a resolved promise reads as "done". | ||
| */ | ||
| function requireTemplateId(action) { | ||
| const templateId = getTemplateId(); | ||
| if (!templateId) throw new SdkError(`[Templatical] Version history: ${action} needs a template. Call create() or load() first.`); | ||
| return templateId; | ||
| } | ||
| function refuse(action) { | ||
| throw new SdkError(`[Templatical] Version history: ${action} is disabled by the provider. Check the capability before calling — the editor's own UI hides the action.`); | ||
| } | ||
| async function load(params) { | ||
| const templateId = requireTemplateId("list"); | ||
| isLoading.value = true; | ||
| try { | ||
| const page = await provider.list(templateId, params); | ||
| versions.value = page.versions; | ||
| nextCursor.value = page.nextCursor; | ||
| } catch (error) { | ||
| options.onError?.(error); | ||
| throw error; | ||
| } finally { | ||
| isLoading.value = false; | ||
| } | ||
| } | ||
| function peekContent(version) { | ||
| return version.content ?? fetched.get(version.id) ?? null; | ||
| } | ||
| async function resolveContent(version) { | ||
| const known = peekContent(version); | ||
| if (known) return known; | ||
| const templateId = requireTemplateId("get"); | ||
| try { | ||
| const content = await provider.get(templateId, version.id); | ||
| fetched.set(version.id, content); | ||
| return content; | ||
| } catch (error) { | ||
| options.onError?.(error); | ||
| throw error; | ||
| } | ||
| } | ||
| async function create(content, meta) { | ||
| const { create: providerCreate } = provider; | ||
| if (typeof providerCreate !== "function") refuse("create"); | ||
| const templateId = requireTemplateId("create"); | ||
| try { | ||
| const created = await providerCreate(templateId, content, meta); | ||
| versions.value = [created, ...versions.value]; | ||
| return created; | ||
| } catch (error) { | ||
| options.onError?.(error); | ||
| throw error; | ||
| } | ||
| } | ||
| async function restore(versionId) { | ||
| const { restore: providerRestore } = provider; | ||
| if (typeof providerRestore !== "function") refuse("restore"); | ||
| const templateId = requireTemplateId("restore"); | ||
| isRestoring.value = true; | ||
| try { | ||
| return await providerRestore(templateId, versionId); | ||
| } catch (error) { | ||
| options.onError?.(error); | ||
| throw error; | ||
| } finally { | ||
| isRestoring.value = false; | ||
| } | ||
| } | ||
| return { | ||
| nextCursor, | ||
| versions, | ||
| isLoading, | ||
| isRestoring, | ||
| canCreate, | ||
| canRestore, | ||
| load, | ||
| peekContent, | ||
| resolveContent, | ||
| create, | ||
| restore | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/comments.ts | ||
| /** | ||
| * Reactive state over a {@link CommentsProvider}. | ||
| * | ||
| * Owns the thread list, the loading flags, the local mutations that keep the list | ||
| * consistent, and the derived counts the chrome renders. Errors are reported | ||
| * through `onError` and **re-thrown**, leaving the list untouched on failure — | ||
| * the same discipline as `useSavedBlocks` and `useVersionHistory`. | ||
| * | ||
| * Mutations **reject** when the provider withheld them rather than resolving to | ||
| * `null`: a resolved promise reads as "saved" to whoever awaited it. Ask through | ||
| * `canCreate` / `canUpdate` / `canDelete` / `canResolve` first — which is what the | ||
| * editor's own UI does, hiding each action rather than disabling it. | ||
| */ | ||
| function useComments(options) { | ||
| const { provider, getTemplateId, getUser } = options; | ||
| const comments = ref$1([]); | ||
| const isLoading = ref$1(false); | ||
| const isSubmitting = ref$1(false); | ||
| const canCreate = computed$1(() => typeof provider.create === "function"); | ||
| const canUpdate = computed$1(() => typeof provider.update === "function"); | ||
| const canDelete = computed$1(() => typeof provider.delete === "function"); | ||
| const canResolve = computed$1(() => typeof provider.setResolved === "function"); | ||
| const totalCount = computed$1(() => comments.value.reduce((sum, thread) => sum + 1 + (thread.replies?.length ?? 0), 0)); | ||
| const unresolvedCount = computed$1(() => comments.value.filter((thread) => !thread.resolvedAt).length); | ||
| const commentCountByBlock = computed$1(() => { | ||
| const map = /* @__PURE__ */ new Map(); | ||
| for (const thread of comments.value) { | ||
| if (!thread.blockId) continue; | ||
| map.set(thread.blockId, (map.get(thread.blockId) ?? 0) + 1 + (thread.replies?.length ?? 0)); | ||
| } | ||
| return map; | ||
| }); | ||
| /** | ||
| * The UI hides disabled actions and renders nothing before a template exists, | ||
| * so reaching one of these means a programmatic caller went around it. Fail | ||
| * loudly rather than silently no-op. | ||
| */ | ||
| function requireTemplateId(action) { | ||
| const templateId = getTemplateId(); | ||
| if (!templateId) throw new SdkError(`[Templatical] Comments: ${action} needs a template. Call create() or load() first.`); | ||
| return templateId; | ||
| } | ||
| function requireUser(action) { | ||
| const user = getUser(); | ||
| if (!user) throw new SdkError(`[Templatical] Comments: ${action} needs to know who is commenting. Pass init({ user: { id, name } }) — an unattributed comment is not written.`); | ||
| return user; | ||
| } | ||
| function refuse(action) { | ||
| throw new SdkError(`[Templatical] Comments: ${action} is disabled by the provider. Check the capability before calling — the editor's own UI hides the action.`); | ||
| } | ||
| function find(commentId) { | ||
| for (const thread of comments.value) { | ||
| if (thread.id === commentId) return thread; | ||
| for (const reply of thread.replies ?? []) if (reply.id === commentId) return reply; | ||
| } | ||
| return null; | ||
| } | ||
| function isOwn(comment) { | ||
| const user = getUser(); | ||
| return user !== null && comment.author.id === user.id; | ||
| } | ||
| function emit(type, comment) { | ||
| options.onComment?.({ | ||
| type, | ||
| comment | ||
| }); | ||
| } | ||
| function report(error) { | ||
| const wrapped = error instanceof Error ? error : new Error(String(error), { cause: error }); | ||
| options.onError?.(wrapped); | ||
| throw wrapped; | ||
| } | ||
| /** | ||
| * Insert a comment, replacing an entry with the same id rather than duplicating | ||
| * it. `create` and a subscribe echo of that same create both land here, which is | ||
| * why a provider does not have to de-duplicate its own broadcasts. | ||
| */ | ||
| function replaceAt(list, index, next) { | ||
| return [ | ||
| ...list.slice(0, index), | ||
| next, | ||
| ...list.slice(index + 1) | ||
| ]; | ||
| } | ||
| function upsert(comment) { | ||
| if (comment.parentId) { | ||
| comments.value = comments.value.map((thread) => { | ||
| if (thread.id !== comment.parentId) return thread; | ||
| const replies = thread.replies ?? []; | ||
| const at = replies.findIndex((reply) => reply.id === comment.id); | ||
| return { | ||
| ...thread, | ||
| replies: at === -1 ? [...replies, comment] : replaceAt(replies, at, comment) | ||
| }; | ||
| }); | ||
| return; | ||
| } | ||
| const at = comments.value.findIndex((thread) => thread.id === comment.id); | ||
| if (at === -1) { | ||
| comments.value = [...comments.value, comment]; | ||
| return; | ||
| } | ||
| comments.value = replaceAt(comments.value, at, { | ||
| ...comment, | ||
| replies: comment.replies ?? comments.value[at].replies | ||
| }); | ||
| } | ||
| function drop(commentId, parentId) { | ||
| const existing = find(commentId); | ||
| const parent = parentId ?? existing?.parentId ?? null; | ||
| if (parent) comments.value = comments.value.map((thread) => thread.id === parent ? { | ||
| ...thread, | ||
| replies: (thread.replies ?? []).filter((r) => r.id !== commentId) | ||
| } : thread); | ||
| else comments.value = comments.value.filter((c) => c.id !== commentId); | ||
| return existing; | ||
| } | ||
| async function load(params) { | ||
| const templateId = requireTemplateId("list"); | ||
| isLoading.value = true; | ||
| try { | ||
| comments.value = await provider.list(templateId, params); | ||
| } catch (error) { | ||
| report(error); | ||
| } finally { | ||
| isLoading.value = false; | ||
| } | ||
| } | ||
| async function create(input) { | ||
| const { create: providerCreate } = provider; | ||
| if (typeof providerCreate !== "function") refuse("create"); | ||
| const templateId = requireTemplateId("create"); | ||
| requireUser("create"); | ||
| isSubmitting.value = true; | ||
| try { | ||
| const created = await providerCreate(templateId, input); | ||
| upsert(created); | ||
| emit("created", created); | ||
| return created; | ||
| } catch (error) { | ||
| report(error); | ||
| } finally { | ||
| isSubmitting.value = false; | ||
| } | ||
| } | ||
| async function update(commentId, patch) { | ||
| const { update: providerUpdate } = provider; | ||
| if (typeof providerUpdate !== "function") refuse("update"); | ||
| const templateId = requireTemplateId("update"); | ||
| requireUser("update"); | ||
| isSubmitting.value = true; | ||
| try { | ||
| const updated = await providerUpdate(templateId, commentId, patch); | ||
| upsert(updated); | ||
| emit("updated", updated); | ||
| return updated; | ||
| } catch (error) { | ||
| report(error); | ||
| } finally { | ||
| isSubmitting.value = false; | ||
| } | ||
| } | ||
| async function remove(commentId) { | ||
| const { delete: providerDelete } = provider; | ||
| if (typeof providerDelete !== "function") refuse("delete"); | ||
| const templateId = requireTemplateId("delete"); | ||
| requireUser("delete"); | ||
| const existing = find(commentId); | ||
| isSubmitting.value = true; | ||
| try { | ||
| await providerDelete(templateId, commentId); | ||
| drop(commentId); | ||
| if (existing) emit("deleted", existing); | ||
| } catch (error) { | ||
| report(error); | ||
| } finally { | ||
| isSubmitting.value = false; | ||
| } | ||
| } | ||
| async function setResolved(commentId, resolved) { | ||
| const { setResolved: providerSetResolved } = provider; | ||
| if (typeof providerSetResolved !== "function") refuse("setResolved"); | ||
| const templateId = requireTemplateId("setResolved"); | ||
| requireUser("setResolved"); | ||
| isSubmitting.value = true; | ||
| try { | ||
| const updated = await providerSetResolved(templateId, commentId, resolved); | ||
| upsert(updated); | ||
| emit(updated.resolvedAt ? "resolved" : "unresolved", updated); | ||
| return updated; | ||
| } catch (error) { | ||
| report(error); | ||
| } finally { | ||
| isSubmitting.value = false; | ||
| } | ||
| } | ||
| function applyRemoteCreate(comment) { | ||
| upsert(comment); | ||
| emit("created", comment); | ||
| } | ||
| function applyRemoteUpdate(comment) { | ||
| upsert(comment); | ||
| emit(comment.resolvedAt ? "resolved" : "updated", comment); | ||
| } | ||
| function applyRemoteDelete(commentId, parentId) { | ||
| const existing = drop(commentId, parentId); | ||
| if (existing) emit("deleted", existing); | ||
| } | ||
| return { | ||
| comments, | ||
| isLoading, | ||
| isSubmitting, | ||
| canCreate, | ||
| canUpdate, | ||
| canDelete, | ||
| canResolve, | ||
| totalCount, | ||
| unresolvedCount, | ||
| commentCountByBlock, | ||
| load, | ||
| create, | ||
| update, | ||
| remove, | ||
| setResolved, | ||
| find, | ||
| isOwn, | ||
| applyRemoteCreate, | ||
| applyRemoteUpdate, | ||
| applyRemoteDelete | ||
| }; | ||
| } | ||
| /** | ||
| * Wire {@link CommentsProvider.subscribe} into the three `applyRemote*` paths. | ||
| * | ||
| * A no-op when the provider has no `subscribe` — realtime is optional, and | ||
| * comments without it work exactly the same except that a colleague's comment | ||
| * only appears on the next read. Nothing here knows about a transport: Cloud's | ||
| * Pusher channel lives inside `createCloudCommentsProvider`, and an SSE or | ||
| * long-poll implementation is the same three lines on the consumer's side. | ||
| * | ||
| * Re-subscribes when the template id changes, and unsubscribes on scope dispose — | ||
| * so a session that loads a second template does not keep receiving the first | ||
| * one's comments. | ||
| */ | ||
| function useCommentListener(options) { | ||
| const { comments, provider, getTemplateId } = options; | ||
| const { subscribe } = provider; | ||
| if (typeof subscribe !== "function") return; | ||
| let unsubscribe = null; | ||
| function stop() { | ||
| unsubscribe?.(); | ||
| unsubscribe = null; | ||
| } | ||
| watch$1(() => getTemplateId(), (templateId) => { | ||
| stop(); | ||
| if (!templateId) return; | ||
| unsubscribe = subscribe(templateId, (change) => { | ||
| switch (change.type) { | ||
| case "created": | ||
| comments.applyRemoteCreate(change.comment); | ||
| break; | ||
| case "updated": | ||
| comments.applyRemoteUpdate(change.comment); | ||
| break; | ||
| case "deleted": comments.applyRemoteDelete(change.commentId, change.parentId); | ||
| } | ||
| }); | ||
| }, { immediate: true }); | ||
| onScopeDispose(stop); | ||
| } | ||
| //#endregion | ||
| export { DEFAULT_AUTO_SAVE_DEBOUNCE_MS, createLocalStorageSavedBlocksProvider, useAutoSave, useBlockActions, useCommentListener, useComments, useConditionPreview, useDataSourceFetch, useEditor, useHistory, useHistoryInterceptor, useSavedBlocks, useVersionHistory }; | ||
| //# sourceMappingURL=index.js.map |
+2
-2
| { | ||
| "name": "@templatical/core", | ||
| "description": "Framework-agnostic editor logic for Templatical email editor", | ||
| "version": "0.26.3", | ||
| "version": "0.27.0", | ||
| "bugs": "https://github.com/templatical/sdk/issues", | ||
| "dependencies": { | ||
| "@vue/reactivity": "^3.5.41", | ||
| "@templatical/types": "0.26.3" | ||
| "@templatical/types": "0.27.0" | ||
| }, | ||
@@ -10,0 +10,0 @@ "devDependencies": { |
| import { Block, TemplateContent, TemplateDefaults, TemplateSettings, UiTheme, ViewportSize } from "@templatical/types"; | ||
| import { DeepReadonly, Ref } from "@vue/reactivity"; | ||
| //#region src/editor.d.ts | ||
| interface EditorState$1 { | ||
| content: TemplateContent; | ||
| selectedBlockId: string | null; | ||
| viewport: ViewportSize; | ||
| darkMode: boolean; | ||
| previewMode: boolean; | ||
| isDirty: boolean; | ||
| uiTheme: UiTheme; | ||
| } | ||
| interface UseEditorOptions { | ||
| content: TemplateContent; | ||
| defaultFontFamily?: string; | ||
| templateDefaults?: TemplateDefaults; | ||
| lockedBlocks?: Ref<Map<string, unknown>>; | ||
| } | ||
| interface UseEditorReturn { | ||
| state: DeepReadonly<EditorState$1>; | ||
| content: Ref<TemplateContent>; | ||
| selectedBlock: Ref<Block | null>; | ||
| setContent: (content: TemplateContent, markDirty?: boolean) => void; | ||
| selectBlock: (blockId: string | null) => void; | ||
| setViewport: (viewport: ViewportSize) => void; | ||
| setDarkMode: (darkMode: boolean) => void; | ||
| setPreviewMode: (previewMode: boolean) => void; | ||
| setUiTheme: (theme: UiTheme) => void; | ||
| updateBlock: (blockId: string, updates: Partial<Block>) => void; | ||
| updateSettings: (updates: Partial<TemplateSettings>) => void; | ||
| addBlock: (block: Block, targetSectionId?: string, columnIndex?: number, index?: number) => void; | ||
| removeBlock: (blockId: string) => void; | ||
| moveBlock: (blockId: string, newIndex: number, targetSectionId?: string, columnIndex?: number) => void; | ||
| isBlockLocked: (blockId: string) => boolean; | ||
| markDirty: () => void; | ||
| findBlockLocation: (blockId: string) => { | ||
| targetSectionId?: string; | ||
| columnIndex?: number; | ||
| index: number; | ||
| } | null; | ||
| } | ||
| declare function useEditor(options: UseEditorOptions): UseEditorReturn; | ||
| //#endregion | ||
| export { useEditor as i, UseEditorOptions as n, UseEditorReturn as r, EditorState$1 as t }; | ||
| //# sourceMappingURL=editor-BIIsaIoN.d.ts.map |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
400928
19.53%4401
18.18%+ Added
- Removed
Updated