/** * Suggestions feature — public type surface for v1. * * Source contract: docs/suggestions-api-contract.md * Source flows: docs/suggestions-implementation-flows.md * * Types are exported from the SDK's public barrel. Customer code interacts * with these via Snippyly.getSuggestionElement(); the SDK constructs all * Suggestion objects internally and customers do not build them directly. */ import { User } from './user.data.model'; /** * Lifecycle state machine for a Suggestion. * * - pending: Just created, awaiting owner action. * - approved: Owner clicked Approve. Customer apply handler ran successfully. * - rejected: Owner clicked Reject (with optional reason). * - stale: Target was unresolvable at approve time. Owner can dismiss. * - apply_failed: Customer apply handler threw during a suggestionApproved event. * Status set by the SDK after the throw was caught. */ export type SuggestionStatus = 'pending' | 'accepted' | 'rejected' | 'stale' | 'apply_failed'; /** * Global per-user-per-session suggestion mode. Not persisted; reload returns to 'editing'. */ export type SuggestionMode = 'editing' | 'suggesting'; /** * Discriminator for the substrate that produced a suggestion. v1: always 'custom'. * Wrapper libraries (e.g. tiptap-velt-comments) widen this union when they integrate. * * Customers should treat unknown values as opaque; the field exists so customer code * can differentiate substrate when domain-specific rendering matters. */ export type SuggestionTargetType = 'custom'; /** * Single-arg config passed to `SuggestionElement.registerTarget(config)`. * Carrying both `targetId` and `getter` in one object so the API can grow * (e.g., metadata, target-specific options) without breaking customers. */ export interface RegisterTargetConfig { targetId: string; getter: TargetGetter; } /** * Function the customer registers via SuggestionElement.registerTarget(config). * Required for any non-primitive (wrapper) target. * * The SDK calls this getter twice in a typical edit cycle: * 1. On focus of the tagged element — to snapshot the pre-edit value. * 2. On commit (focusout / change) — to read the current value for the diff. * * IMPORTANT — edit-time state, not persisted state: the getter must reflect * what the user is currently editing, not what's persisted in the customer's * app store. If the customer's state is only updated on commit/approve (as * is typical when suggesting mode is on), reading from that state would * return the snapshot value at commit time, the diff check would short- * circuit, and no suggestion would ever fire. * * Recommended: read from the DOM (controlled or uncontrolled inputs) so the * getter always returns what's visible to the user. * * el.registerTarget('row.123', () => ({ * qty: parseInt(qtyInput.value, 10), * price: parseInt(priceInput.value, 10), * })); * * For controlled inputs (state updates on every keystroke), reading from * the customer state is fine: `() => myState.row123`. The contract is * "edit-time state" — whichever source of truth has it. */ export type TargetGetter = () => T; /** * Returned from SuggestionElement.on(...). Calling it removes the handler. */ export type Unsubscribe = () => void; /** * Details about a target edit, passed to the customer's resolver handler and * to subscribers of `targetEditStart` / `targetEditCommit` events. * * `element` is the DOM element that produced the edit. It's the tagged * descendant — or, for wrappers that nest interactive controls inside a * tagged container, the inner element that actually fired the event. In * either case the SDK walks up to the nearest ancestor carrying * `data-velt-suggestion-target` to resolve `targetId`. * * `element` is provided for read access only — customers should not retain * the reference past the synchronous handler call. */ export interface TargetEditDetails { targetId: string; oldValue: T; newValue: T; element: Element | null; } /** * Return type of `onTargetEditCommit`. Returning a value auto-commits the * suggestion using the (optional) overrides; returning null skips the * auto-commit so a subscriber to `targetEditCommit` can drive it explicitly. */ export interface TargetEditCommitResult { /** * Override the SDK's default diff summary (`Replace: "{old}" with "{new}"`; * `Add:`/`Remove:` when one side is absent; a per-key op list when both * values are plain objects). When provided without `summaryHtml`, it is * used for BOTH the plain `commentText` and (escaped) the `commentHtml` * of the seeded first comment. */ summary?: string; /** * Optional HTML version of the summary, used as the `commentHtml` of the * suggestion's seeded first comment (the plain `summary` — or the SDK's * default diff summary — stays in `commentText`). When BOTH are omitted, * the SDK renders a default styled HTML diff (old/removed values in red * italic, new/added values in green italic). Sanitized with DOMPurify at * render time — script/event-handler content is stripped; inline `style` * attributes are preserved. */ summaryHtml?: string; /** Customer metadata persisted on the resulting Suggestion. */ metadata?: Record; } /** * Return type of `onTargetEditStart`. Reserved for future fields; v1 has * no behavior-bearing return values, so customers may omit a return. * * Roadmap: future versions may accept `{ oldValue }` here so customers can * override the SDK's auto-snapshot with a domain-canonical value. */ export interface TargetEditStartResult { } export type TargetEditStartHandler = (details: TargetEditDetails) => TargetEditStartResult | void | null; export type TargetEditCommitHandler = (details: TargetEditDetails) => TargetEditCommitResult | null; /** * Config passed to `SuggestionElement.enableSuggestionMode(config?)`. * Both callbacks are optional. If `onTargetEditCommit` is omitted, customers * can still drive auto-commit by subscribing to the `targetEditCommit` event * and calling the pre-bound `commitSuggestion` builder on the payload. */ export interface EnableSuggestionModeConfig { /** * Invoked on focus of a tagged element, after the SDK captures the * snapshot. v1: informational — return value is reserved for future * use (e.g. `{ oldValue }` to override the SDK's auto-snapshot). */ onTargetEditStart?: TargetEditStartHandler; /** * Invoked once per detected commit (on `change` for atomic inputs, on * `focusout` for text-like inputs). Returning a `TargetEditCommitResult` * auto-commits with the supplied summary/metadata; returning null defers * to event subscribers. */ onTargetEditCommit?: TargetEditCommitHandler; /** * When active AND no `onTargetEditCommit` handler is provided, every * detected commit auto-commits with the SDK's default summary/summaryHtml * (equivalent to a handler returning `{}`). * * **Default: true** — a bare `enableSuggestionMode()` turns edits into * suggestions with zero config. Pass `autoCommit: false` for detect-only * mode (events fire, nothing commits until a subscriber calls the event's * `commitSuggestion` builder). * * A provided `onTargetEditCommit` handler always wins — including its * null-return deferral contract — so handler-based integrations are * unaffected. The `targetEditCommit` event still fires; its pre-bound * `commitSuggestion` builder becomes a no-op for edits already * auto-committed by this flag (same double-commit guard as the handler * path). */ autoCommit?: boolean; } /** * SDK-managed suggestion data persisted on a CommentAnnotation. * Present iff annotation.type === 'suggestion'. * * Customer code must not write to this directly — use the SuggestionElement API. */ export interface SuggestionData { /** Lifecycle state machine. */ status: SuggestionStatus; /** Stable, customer-owned target identifier. */ targetId: string; /** v1: always 'custom'. Wrappers widen later. */ targetType: SuggestionTargetType; /** Snapshot taken at startSuggestion / focus time. Frozen via structuredClone. */ oldValue: any; /** Value submitted via commitSuggestion. */ newValue: any; /** Optional human-readable description for logs / notifications. */ summary: string | null; /** * True if the live value at approve time differed from oldValue. * v1 records flag only; v1.1 will surface a confirmation prompt. */ driftDetected: boolean; /** Populated only when status === 'rejected'. */ rejectReason: string | null; /** * Full User snapshot of the resolver, populated when status moves to * approved | rejected | stale | apply_failed. Snapshot rather than userId * so customer code can render name/email/photoUrl without an extra lookup, * and so historical suggestions retain their resolver record even if the * user later updates their profile. */ resolvedBy: User | null; resolvedAt: number | null; } /** * Fields shared across every Suggestion regardless of status. * Internal — used to build the per-status discriminated types below. */ interface SuggestionBase { /** Annotation document id (same as the underlying CommentAnnotation.annotationId). */ annotationId: string; /** Stable, customer-owned target identifier. */ targetId: string; /** v1: always 'custom'. */ targetType: SuggestionTargetType; /** Snapshot taken at startSuggestion / focus time. */ oldValue: T; /** Value submitted via commitSuggestion. */ newValue: T; /** Optional human-readable description. */ summary: string | null; /** Customer-defined metadata supplied via CommitSuggestionConfig.metadata. */ metadata: Record; /** True iff the live value at approve time differed from oldValue. */ driftDetected: boolean; /** * User of the suggestion's creator (sourced from annotation.from). */ createdBy?: User; createdAt: number; } /** A suggestion in the 'pending' state — newly created, no owner action yet. */ export interface PendingSuggestion extends SuggestionBase { status: 'pending'; rejectReason: null; resolvedBy: null; resolvedAt: null; } /** A suggestion that has been approved (apply handler success or pending invocation). */ export interface ApprovedSuggestion extends SuggestionBase { status: 'accepted' | 'apply_failed'; rejectReason: null; resolvedBy: User; resolvedAt: number; } /** * A suggestion that has been rejected. `rejectReason` may be null when the * rejecter dismissed without supplying a reason — matches the persisted * `SuggestionData.rejectReason: string | null` shape. */ export interface RejectedSuggestion extends SuggestionBase { status: 'rejected'; rejectReason: string | null; resolvedBy: User; resolvedAt: number; } /** A suggestion whose target was unresolvable at approve time. */ export interface StaleSuggestion extends SuggestionBase { status: 'stale'; rejectReason: null; resolvedBy: User | null; resolvedAt: number | null; } /** * Public Suggestion — discriminated union keyed by `status`. TypeScript narrows * field types per status (e.g. `resolvedBy: User` is non-null on approved/rejected, * `rejectReason: string | null` on rejected since one-click reject without a reason * is supported). */ export type Suggestion = PendingSuggestion | ApprovedSuggestion | RejectedSuggestion | StaleSuggestion; export interface CommitSuggestionConfig { /** * Must be registered (via data-velt-target attribute or registerTarget call) * before commit, otherwise the suggestion is rejected with a dev-mode warning. */ targetId: string; /** Any JSON-serializable value. Customer's apply handler interprets it. */ newValue: T; /** * Optional human-readable string for logs and notifications. When provided * without `summaryHtml`, it is used for BOTH the seeded comment's plain * `commentText` and (escaped) its `commentHtml`. When omitted, the seeded * comment falls back to the SDK's default diff summary * (`Replace: "{old}" with "{new}"`; `Add:`/`Remove:` when one side is * absent; a per-key op list when both values are plain objects). */ summary?: string; /** * Optional HTML version of the summary — becomes the `commentHtml` of the * suggestion's seeded first comment (plain `summary` or the default diff * summary stays in `commentText`). When BOTH are omitted, the SDK renders * a default styled HTML diff (old/removed values in red italic, new/added * values in green italic). Sanitized with DOMPurify at render time; inline * `style` attributes are preserved so rich diff styling survives. */ summaryHtml?: string; /** Optional customer-defined metadata. Stored on Suggestion.metadata. */ metadata?: Record; } /** * Pre-bound builder attached to `targetEditCommit` payloads. Calling it * commits the suggestion with the SDK's default summary/metadata, optionally * overridden by `result`. If the customer's `onTargetEditCommit` handler * already returned a non-null result for this edit, this builder is a no-op * (resolves immediately) so subscribers can't double-commit. */ export type TargetEditCommitBuilder = (result?: TargetEditCommitResult) => Promise<{ id: string; } | null>; export interface SuggestionGetSuggestionsFilter { targetId?: string; status?: SuggestionStatus | SuggestionStatus[]; } export {};