diff --git a/.github/workflows/pr-renderer-build.yml b/.github/workflows/pr-renderer-build.yml index db441f4391..4432e5f35a 100644 --- a/.github/workflows/pr-renderer-build.yml +++ b/.github/workflows/pr-renderer-build.yml @@ -1,4 +1,4 @@ -name: "Labs: PR Renderer Build" +name: 'Labs: PR Renderer Build' on: pull_request: @@ -337,8 +337,6 @@ jobs: echo "renderer_build_id=${RENDERER_BUILD_ID}" >> "${GITHUB_OUTPUT}" echo "renderer_build_status=${RENDERER_BUILD_STATUS}" >> "${GITHUB_OUTPUT}" -<<<<<<< Updated upstream -======= - name: Wait for Labs renderer artifact id: wait_renderer_artifact env: @@ -644,7 +642,6 @@ jobs: echo "| New run created | \`${{ steps.dispatch.outputs.created }}\` |" } >> "${GITHUB_STEP_SUMMARY}" ->>>>>>> Stashed changes cleanup: name: Cleanup if: >- diff --git a/.github/workflows/release-qualification-dispatch.yml b/.github/workflows/release-qualification-dispatch.yml deleted file mode 100644 index 79566f1b13..0000000000 --- a/.github/workflows/release-qualification-dispatch.yml +++ /dev/null @@ -1,212 +0,0 @@ -name: ๐Ÿงช Dispatch Release Qualification - -on: - pull_request: - branches: - - stable - types: - - opened - - ready_for_review - - reopened - - synchronize - -permissions: - contents: read - pull-requests: read - -concurrency: - group: release-qualification-dispatch-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - dispatch-release-qualification: - name: Release Qualification - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Build dispatch payload - env: - PR_TITLE: ${{ github.event.pull_request.title }} - run: | - set -euo pipefail - - MERGE_PREPARATION_STATUS="unknown" - if [[ "${PR_TITLE}" == *"conflicts need resolution"* ]]; then - MERGE_PREPARATION_STATUS="conflicts" - fi - - cat < release-qualification-payload.json - { - "repositoryOwner": "${{ github.repository_owner }}", - "repositoryName": "${GITHUB_REPOSITORY#*/}", - "repositoryFullName": "${{ github.repository }}", - "pullRequestNumber": ${{ github.event.pull_request.number }}, - "pullRequestUrl": "${{ github.event.pull_request.html_url }}", - "baseRef": "${{ github.event.pull_request.base.ref }}", - "headRef": "${{ github.event.pull_request.head.ref }}", - "headSha": "${{ github.event.pull_request.head.sha }}", - "mergePreparationStatus": "${MERGE_PREPARATION_STATUS}", - "triggerEvent": "${{ github.event.action }}" - } - EOF - - - name: Dispatch to Labs release orchestrator - id: dispatch - env: - LABS_RELEASE_QUALIFICATION_TOKEN: ${{ secrets.LABS_RELEASE_QUALIFICATION_TOKEN }} - LABS_RELEASE_QUALIFICATION_URL: ${{ vars.LABS_RELEASE_QUALIFICATION_URL }} - run: | - set -euo pipefail - - if [[ -z "${LABS_RELEASE_QUALIFICATION_URL}" ]]; then - echo "LABS_RELEASE_QUALIFICATION_URL is required." >&2 - exit 1 - fi - - if [[ -z "${LABS_RELEASE_QUALIFICATION_TOKEN}" ]]; then - echo "LABS_RELEASE_QUALIFICATION_TOKEN is required." >&2 - exit 1 - fi - - RESPONSE_FILE="$(mktemp)" - set +e - HTTP_STATUS="$(curl \ - --fail-with-body \ - --silent \ - --show-error \ - --output "${RESPONSE_FILE}" \ - --write-out '%{http_code}' \ - -X POST \ - -H 'content-type: application/json' \ - -H "x-labs-internal-token: ${LABS_RELEASE_QUALIFICATION_TOKEN}" \ - --data @release-qualification-payload.json \ - "${LABS_RELEASE_QUALIFICATION_URL}")" - CURL_EXIT=$? - set -e - - if [[ "${CURL_EXIT}" -ne 0 ]]; then - cat "${RESPONSE_FILE}" - exit "${CURL_EXIT}" - fi - - if [[ "${HTTP_STATUS}" -lt 200 || "${HTTP_STATUS}" -ge 300 ]]; then - cat "${RESPONSE_FILE}" - exit 1 - fi - - RUN_ID="$(jq -r '.run.runId // empty' "${RESPONSE_FILE}")" - RUN_STATUS="$(jq -r '.run.status // empty' "${RESPONSE_FILE}")" - RUN_STATUS_MESSAGE="$(jq -r '.run.statusMessage // empty' "${RESPONSE_FILE}")" - CREATED="$(jq -r '.created // false' "${RESPONSE_FILE}")" - - if [[ -z "${RUN_ID}" || -z "${RUN_STATUS}" ]]; then - cat "${RESPONSE_FILE}" - echo "Labs response did not include the expected run metadata." >&2 - exit 1 - fi - - RUN_STATUS_URL="${LABS_RELEASE_QUALIFICATION_URL%/}/${RUN_ID}" - - echo "run_id=${RUN_ID}" >> "${GITHUB_OUTPUT}" - echo "run_status=${RUN_STATUS}" >> "${GITHUB_OUTPUT}" - echo "run_status_message=${RUN_STATUS_MESSAGE}" >> "${GITHUB_OUTPUT}" - echo "run_status_url=${RUN_STATUS_URL}" >> "${GITHUB_OUTPUT}" - echo "created=${CREATED}" >> "${GITHUB_OUTPUT}" - - - name: Wait for Labs release qualification result - id: await - env: - INITIAL_RUN_STATUS: ${{ steps.dispatch.outputs.run_status }} - INITIAL_RUN_STATUS_MESSAGE: ${{ steps.dispatch.outputs.run_status_message }} - LABS_RELEASE_QUALIFICATION_TOKEN: ${{ secrets.LABS_RELEASE_QUALIFICATION_TOKEN }} - RUN_STATUS_URL: ${{ steps.dispatch.outputs.run_status_url }} - run: | - set -euo pipefail - - RUN_STATUS="${INITIAL_RUN_STATUS}" - RUN_STATUS_MESSAGE="${INITIAL_RUN_STATUS_MESSAGE}" - - while [[ "${RUN_STATUS}" == "queued" || "${RUN_STATUS}" == "in_progress" ]]; do - RESPONSE_FILE="$(mktemp)" - set +e - HTTP_STATUS="$(curl \ - --fail-with-body \ - --silent \ - --show-error \ - --output "${RESPONSE_FILE}" \ - --write-out '%{http_code}' \ - -H "x-labs-internal-token: ${LABS_RELEASE_QUALIFICATION_TOKEN}" \ - "${RUN_STATUS_URL}")" - CURL_EXIT=$? - set -e - - if [[ "${CURL_EXIT}" -ne 0 ]]; then - cat "${RESPONSE_FILE}" - exit "${CURL_EXIT}" - fi - - if [[ "${HTTP_STATUS}" -lt 200 || "${HTTP_STATUS}" -ge 300 ]]; then - cat "${RESPONSE_FILE}" - exit 1 - fi - - RUN_STATUS="$(jq -r '.run.status // empty' "${RESPONSE_FILE}")" - RUN_STATUS_MESSAGE="$(jq -r '.run.statusMessage // empty' "${RESPONSE_FILE}")" - - if [[ -z "${RUN_STATUS}" ]]; then - cat "${RESPONSE_FILE}" - echo "Labs run lookup did not include a terminal status." >&2 - exit 1 - fi - - if [[ "${RUN_STATUS}" == "queued" || "${RUN_STATUS}" == "in_progress" ]]; then - sleep 10 - fi - done - - echo "run_status=${RUN_STATUS}" >> "${GITHUB_OUTPUT}" - echo "run_status_message=${RUN_STATUS_MESSAGE}" >> "${GITHUB_OUTPUT}" - - - name: Enforce Labs release qualification result - env: - FINAL_RUN_STATUS: ${{ steps.await.outputs.run_status }} - FINAL_RUN_STATUS_MESSAGE: ${{ steps.await.outputs.run_status_message }} - run: | - set -euo pipefail - - case "${FINAL_RUN_STATUS}" in - succeeded) - exit 0 - ;; - superseded) - echo "${FINAL_RUN_STATUS_MESSAGE:-Release qualification was superseded by a newer run.}" - exit 0 - ;; - failed|action_required) - echo "${FINAL_RUN_STATUS_MESSAGE:-Release qualification failed.}" >&2 - exit 1 - ;; - *) - echo "Unexpected Labs release qualification status: ${FINAL_RUN_STATUS}" >&2 - exit 1 - ;; - esac - - - name: Write workflow summary - if: always() - run: | - { - echo "### Release Qualification" - echo - echo "| Field | Value |" - echo "| --- | --- |" - echo "| PR | #${{ github.event.pull_request.number }} |" - echo "| Base branch | \`${{ github.event.pull_request.base.ref }}\` |" - echo "| Head branch | \`${{ github.event.pull_request.head.ref }}\` |" - echo "| Head SHA | \`${{ github.event.pull_request.head.sha }}\` |" - echo "| Labs run | \`${{ steps.dispatch.outputs.run_id }}\` |" - echo "| Labs status | \`${{ steps.await.outputs.run_status }}\` |" - echo "| Labs status message | ${{ steps.await.outputs.run_status_message || 'n/a' }} |" - echo "| New run created | \`${{ steps.dispatch.outputs.created }}\` |" - } >> "${GITHUB_STEP_SUMMARY}" diff --git a/cicd.md b/cicd.md index 035b9898a1..44813e95e4 100644 --- a/cicd.md +++ b/cicd.md @@ -81,21 +81,25 @@ main (next) โ†’ stable (latest) โ†’ X.x (maintenance) - If the merge conflicts, commits the conflicted merge to the branch so a human can resolve it there - Merging that PR triggers the automatic stable release workflow -#### 4. Release Qualification Dispatch (`release-qualification-dispatch.yml`) +#### 4. Labs PR Build and Stable Promotion Checks (`pr-renderer-build.yml`) -**Trigger**: Pull requests targeting `stable` (`opened`, `reopened`, `synchronize`, `ready_for_review`) +**Trigger**: Pull requests targeting `main` or `stable` (`opened`, `reopened`, `synchronize`, `ready_for_review`, `closed`) **Actions**: -- Sends the PR head SHA and branch metadata to the Labs release-orchestrator service -- Polls Labs for the terminal release-qualification state -- Uses the GitHub Actions job itself as the required public status check -- Re-triggers automatically when new commits are pushed to the PR branch +- Builds a `superdoc.tgz` tarball for the PR head SHA +- Uploads the package artifact to Labs +- Registers the PR renderer build in Labs +- For PRs targeting `stable`, runs `Labs: Stable promotion checks` after the PR renderer build is registered +- Polls Labs for the terminal stable-promotion state +- Cleans up registered Labs artifacts when a same-repository PR is merged Only same-repository PRs dispatch to Labs. Forked PRs are intentionally skipped so private Labs credentials are never exposed to untrusted branches. **Required configuration**: +- variable: `LABS_API_URL` +- secret: `LABS_PR_BUILD_TOKEN` (falls back to `LABS_RELEASE_QUALIFICATION_TOKEN`) - variable: `LABS_RELEASE_QUALIFICATION_URL` - secret: `LABS_RELEASE_QUALIFICATION_TOKEN` @@ -229,9 +233,9 @@ These skip semantic-release entirely โ€” useful for re-publishing a failed platf 1. Run "Promote to Stable" workflow 2. Review the generated PR from the candidate branch into `stable` -3. Labs receives the PR head SHA, records the qualification run, and the workflow job polls Labs for the terminal result +3. Labs receives the PR package artifact, registers a PR renderer build, and then runs stable promotion checks for that exact PR head SHA 4. If needed, resolve merge conflicts on the candidate branch and push fixes -5. Re-run or wait for qualification on the new PR head SHA +5. Re-run or wait for the stable promotion checks on the new PR head SHA 6. Merge the PR into `stable` 7. Automatically publishes `1.1.0` as @latest 8. Syncs back to main with version bump diff --git a/packages/esign/package.json b/packages/esign/package.json index e000d7b336..98fd1b79f1 100644 --- a/packages/esign/package.json +++ b/packages/esign/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/esign", - "version": "2.6.0", + "version": "2.6.1", "description": "React eSignature component for SuperDoc", "type": "module", "main": "./dist/index.js", diff --git a/packages/react/package.json b/packages/react/package.json index 32b0f02d9b..0f3cdf97f3 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/react", - "version": "1.2.0", + "version": "1.2.1", "description": "Official React wrapper for the SuperDoc document editor", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts index 4eec24a3f9..47a163823b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { NodeSelection } from 'prosemirror-state'; import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Editor } from '../../core/Editor.js'; import { resolveCurrentSelectionInfo } from './selection-info-resolver.js'; @@ -143,14 +144,31 @@ function doc(blocks: ProseMirrorNode[]): ProseMirrorNode { return createNode('doc', blocks, { isBlock: false, inlineContent: false }); } +function makeRealNodeSelection( + from: number, + to: number, + node: { type: { name: string }; isBlock: boolean; isLeaf: boolean; isInline: boolean; nodeSize: number }, +): NodeSelection { + const sel = Object.create(NodeSelection.prototype); + Object.defineProperty(sel, 'from', { value: from, configurable: true }); + Object.defineProperty(sel, 'to', { value: to, configurable: true }); + Object.defineProperty(sel, 'empty', { value: false, configurable: true }); + Object.defineProperty(sel, 'node', { value: node, configurable: true }); + return sel as NodeSelection; +} + /** Minimal editor stub whose doc + selection are controllable per test. */ -function makeEditor(docNode: ProseMirrorNode, selection: { from: number; to: number; empty?: boolean }): Editor { +function makeEditor( + docNode: ProseMirrorNode, + selection: { from: number; to: number; empty?: boolean; node?: unknown }, +): Editor { const empty = selection.empty ?? selection.from === selection.to; + const pmSelection = 'node' in selection ? selection : { from: selection.from, to: selection.to, empty }; const listeners = new Map void>>(); return { state: { doc: docNode, - selection: { from: selection.from, to: selection.to, empty }, + selection: pmSelection, storedMarks: null, }, on(event: string, listener: () => void) { @@ -215,6 +233,41 @@ describe('resolveCurrentSelectionInfo', () => { ]); }); + it('returns null target for a NodeSelection over an addressable text block', () => { + // SelectionInfo.target is only for text selections. A NodeSelection + // over a text-bearing block still represents the node, not a user text + // range that can safely feed comments.create. + const paragraph = textBlock('p1', 'Hello'); + const docNode = doc([paragraph]); + const selection = makeRealNodeSelection(1, 1 + paragraph.nodeSize, paragraph as any); + const editor = makeEditor(docNode, selection); + + const info = resolveCurrentSelectionInfo(editor, {}); + + expect(info.empty).toBe(false); + expect(info.target).toBeNull(); + }); + + it('returns null target for a NodeSelection over a text-bearing structured content block', () => { + // Presentation clicks can select a block SDT as a NodeSelection. Even + // though the wrapper contains textblocks, the selection itself is not + // a text selection and should not be projected into a TextTarget. + const innerParagraph = textBlock('p-inside-sdt', 'Field text'); + const blockSdt = createNode('structuredContentBlock', [innerParagraph], { + isBlock: true, + inlineContent: false, + attrs: { sdBlockId: 'sdt-1' }, + }); + const docNode = doc([blockSdt]); + const selection = makeRealNodeSelection(1, 1 + blockSdt.nodeSize, blockSdt as any); + const editor = makeEditor(docNode, selection); + + const info = resolveCurrentSelectionInfo(editor, {}); + + expect(info.empty).toBe(false); + expect(info.target).toBeNull(); + }); + it('returns null target when no selected block has an addressable blockId', () => { // Block without sdBlockId / id / blockId โ€” resolver skips it. const textNode = createNode('text', [], { text: 'Hello' }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts index c90834f5db..8c6d90f3c0 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts @@ -1,6 +1,7 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { SelectionCurrentInput, SelectionInfo, TextTarget, TextSegment } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; +import { NodeSelection } from 'prosemirror-state'; import { pmPositionToTextOffset } from './text-offset-resolver.js'; import { groupTrackedChanges } from './tracked-change-resolver.js'; import { resolveCommentIdFromAttrs } from './value-utils.js'; @@ -42,7 +43,7 @@ export function resolveCurrentSelectionInfo(editor: Editor, input: SelectionCurr // `collectTextSegments` returns null when any selected block lacks a // stable id โ€” in that case the caller should treat the selection as // unaddressable rather than receive a partial TextTarget. - const segments = collectTextSegments(state.doc, from, to); + const segments = shouldProjectTextTarget(sel) ? collectTextSegments(state.doc, from, to) : null; const target: TextTarget | null = segments && segments.length > 0 ? buildTextTarget(segments) : null; const activeMarks = collectActiveMarks(state, from, to); @@ -80,6 +81,13 @@ function buildTextTarget(segments: TextSegment[]): TextTarget { }; } +function shouldProjectTextTarget(selection: unknown): boolean { + if (!selection || typeof selection !== 'object') return false; + if (selection instanceof NodeSelection) return false; + if ('$anchorCell' in selection) return false; + return true; +} + /** * Walk every textblock touched by [from, to] and emit one segment per block * with block-relative flattened-text offsets. diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts index 3e35251a09..3a44dce750 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.test.ts @@ -716,6 +716,61 @@ describe('comments-wrappers: addCommentHandler multi-segment targets', () => { }); }); + it('treats a TextTarget with a stray blockId but no range as TextTarget, not TextAddress', () => { + // The public validator accepts this as a TextTarget because the + // segments array is valid. A stray blockId alone is not enough to form + // a TextAddress, so the adapter must not fall through to the + // single-block path and dereference target.range. + const editor = makeWriteEditor(); + vi.mocked(resolveTextTarget).mockReturnValue({ from: 11, to: 17 }); + vi.mocked(executeDomainCommand).mockReturnValue({ + steps: [{ effect: 'changed' }], + } as unknown as ReturnType); + + const wrapper = createCommentsWrapper(editor); + const target = { + kind: 'text', + blockId: 'partial-address-only', + segments: [{ blockId: 'pZ', range: { start: 2, end: 8 } }], + } as unknown as Parameters[0]['target']; + + expect(() => wrapper.add({ text: 'comment', target })).not.toThrow(); + expect(resolveTextTarget).toHaveBeenCalledTimes(1); + expect(resolveTextTarget).toHaveBeenCalledWith(editor, { + kind: 'text', + blockId: 'pZ', + range: { start: 2, end: 8 }, + }); + }); + + it('treats a TextTarget with a stray range but no blockId as TextTarget, not TextAddress', () => { + // Same partial-hybrid class as above: this is valid as a TextTarget, + // but not as a TextAddress. The adapter should resolve the real + // segment instead of manufacturing a segment from the stray range. + const editor = makeWriteEditor(); + vi.mocked(resolveTextTarget).mockReturnValue({ from: 11, to: 17 }); + vi.mocked(executeDomainCommand).mockReturnValue({ + steps: [{ effect: 'changed' }], + } as unknown as ReturnType); + + const wrapper = createCommentsWrapper(editor); + const target = { + kind: 'text', + range: { start: 99, end: 100 }, + segments: [{ blockId: 'pZ', range: { start: 2, end: 8 } }], + } as unknown as Parameters[0]['target']; + + const receipt = wrapper.add({ text: 'comment', target }); + + expect(receipt.success).toBe(true); + expect(resolveTextTarget).toHaveBeenCalledTimes(1); + expect(resolveTextTarget).toHaveBeenCalledWith(editor, { + kind: 'text', + blockId: 'pZ', + range: { start: 2, end: 8 }, + }); + }); + it('rejects a TextTarget with collapsed segments in different blocks', () => { // Regression: two collapsed segments in different blocks would slip // both the gap check and the spanning-range collapse check (because diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts index cdc892dcbc..a828ee85a4 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/comments-wrappers.ts @@ -82,25 +82,48 @@ function isSameTarget( return left.blockId === right.blockId && left.range.start === right.range.start && left.range.end === right.range.end; } +/** + * Check whether a payload carries a complete TextAddress. The + * document-api input validator accepts a payload if it satisfies either + * `isTextAddress` or `isTextTarget`; neither validator rejects extra + * fields, so a full hybrid payload (`{ kind: 'text', blockId, range, + * segments }`) passes both. A complete TextAddress is more specific and + * takes precedence over `segments`. + */ +function isTextAddressShape( + target: unknown, +): target is { kind: 'text'; blockId: string; range: { start: number; end: number } } { + if (!target || typeof target !== 'object') return false; + const t = target as { kind?: unknown; blockId?: unknown; range?: unknown }; + if (t.kind !== 'text') return false; + if (typeof t.blockId !== 'string') return false; + return isTextRangeShape(t.range); +} + +function isTextRangeShape(range: unknown): range is { start: number; end: number } { + if (!range || typeof range !== 'object') return false; + const r = range as { start?: unknown; end?: unknown }; + return Number.isInteger(r.start) && Number.isInteger(r.end) && (r.start as number) <= (r.end as number); +} + +function isTextSegmentShape(segment: unknown): segment is TextSegment { + if (!segment || typeof segment !== 'object') return false; + const seg = segment as { blockId?: unknown; range?: unknown }; + return typeof seg.blockId === 'string' && isTextRangeShape(seg.range); +} + /** * Check whether a payload should be routed through the multi-segment - * TextTarget branch. The document-api input validator accepts a payload - * if it satisfies *either* `isTextAddress` or `isTextTarget`; neither - * validator rejects extra fields, so a hybrid payload (`{ kind: 'text', - * blockId, range, segments }`) passes both. To avoid silently dropping - * the hybrid's `blockId`/`range` data, we require a *pure* TextTarget - * here: `kind: 'text'`, a non-empty `segments` array, AND the absence - * of TextAddress-style `blockId`/`range`. Hybrids fall through to the - * single-block branch, which is the more specific shape. + * TextTarget branch. Extra partial TextAddress fields are ignored here: + * a stray `blockId` without `range`, or `range` without `blockId`, is + * not enough to override a valid `segments` payload. */ function isTextTargetShape(target: unknown): target is TextTarget { if (!target || typeof target !== 'object') return false; - const t = target as { kind?: unknown; segments?: unknown; blockId?: unknown; range?: unknown }; + const t = target as { kind?: unknown; segments?: unknown }; if (t.kind !== 'text') return false; if (!Array.isArray(t.segments) || t.segments.length === 0) return false; - // Reject hybrid payloads โ€” TextAddress fields take precedence so the - // adapter doesn't silently ignore caller-provided block/range data. - if (typeof t.blockId === 'string' || (t.range !== undefined && t.range !== null)) return false; + if (!t.segments.every(isTextSegmentShape)) return false; return true; } @@ -110,9 +133,10 @@ function isTextTargetShape(target: unknown): target is TextTarget { */ function targetToSegments( target: { kind: 'text'; blockId: string; range: { start: number; end: number } } | TextTarget, -): TextSegment[] { +): TextSegment[] | null { + if (isTextAddressShape(target)) return [{ blockId: target.blockId, range: target.range }]; if (isTextTargetShape(target)) return [...target.segments]; - return [{ blockId: target.blockId, range: target.range }]; + return null; } function listCommentAnchorsSafe(editor: Editor): ReturnType { @@ -415,6 +439,16 @@ function addCommentHandler(editor: Editor, input: AddCommentInput, options?: Rev }; } const segments = targetToSegments(target); + if (!segments) { + return { + success: false, + failure: { + code: 'INVALID_TARGET', + message: 'Comment target must be a TextAddress or TextTarget.', + details: { target }, + }, + }; + } // Per-segment collapse check. Without this, two collapsed segments in // different blocks (e.g. caret at end of p1 and caret at start of p2) diff --git a/packages/template-builder/package.json b/packages/template-builder/package.json index 576c00b048..eab931b8ba 100644 --- a/packages/template-builder/package.json +++ b/packages/template-builder/package.json @@ -1,6 +1,6 @@ { "name": "@superdoc-dev/template-builder", - "version": "1.7.0", + "version": "1.8.0", "description": "React template builder component for SuperDoc", "type": "module", "main": "./dist/index.js",