diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bf8f593..2ff6dea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,11 +6,19 @@ Block-based editor (Craft/Notion-like) for Compose Multiplatform. Unidirectional | Concept | File | Key Symbol | |---------|------|------------| -| Sample app shell/navigation | `sample/src/commonMain/kotlin/io/github/linreal/cascade/App.kt`, `sample/src/commonMain/kotlin/io/github/linreal/cascade/navigation/AppScreen.kt` | `App()`, `AppScreen.saveKey`, `AppScreen.fromSaveKey()` | +| Sample app shell/navigation | `sample/src/commonMain/kotlin/io/github/linreal/cascade/App.kt`, `sample/src/commonMain/kotlin/io/github/linreal/cascade/navigation/AppScreen.kt` | `App()`, `AppScreen.saveKey`, `AppScreen.fromSaveKey()` (object destinations plus the parameterized `AppScreen.PreviewDocumentEditor(documentId)`, whose save key is prefix-encoded) | | Sample editor top-bar chrome | `sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/EditorChrome.kt` | `EditorTopBar()`, `SelectionTopBar()`, `SavedPill()`, `OpenedLinkPill()` (shared by editor screens; maps the chrome design onto `MaterialTheme.colorScheme`) | | Sample comments screen | `sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/comments/CommentsScreen.kt`, `CommentComposer.kt`, `CommentsScreenModel.kt`, `CommentModel.kt` | `CommentsScreen()`, `CommentComposer()` (editor-as-chat-composer: focus-driven fade-in formatting bar via `rememberCascadeEditorToolbarController` + Send), `CommentsScreenModel` (`buildOwnComment()`/`resetComposer()`), `Comment` + `rememberCommentAnnotatedString()` (consumer-side `SpanStyle`→`AnnotatedString`) | +| Sample preview gallery | `sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/PreviewGalleryScreen.kt` | `PreviewGalleryScreen()` (50 documents in one outer `LazyVerticalGrid`, stable item keys, hoisted registry/theme/localization/config, built-in/custom/fallback/link fixtures, card tap navigates by document ID) | +| Sample preview document store | `sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentLibrary.kt`, `PreviewDocumentFixtures.kt` | `PreviewDocumentLibrary` (single JSON persistence format; `save()` republishes only after a clean decode and keeps other entries referentially identical so keyed cards do not churn), `PreviewDocument`, `PreviewMetricBlockType` + `PreviewSampleTypeCodec` (custom type survives grid → editor → grid) | +| Sample preview editor destination | `sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentEditorScreen.kt` | `PreviewDocumentEditorScreen()` (editor state created only on the destination, loads that document's JSON, debounced autosave plus flush before Back, publishes through `PreviewDocumentLibrary`) | | Main composable | `ui/CascadeEditor.kt` | `CascadeEditor(stateHolder, textStates, spanStates, registry, slashRegistry, slashCommand, ...)` | -| Native iOS SDK technical context | `docs/iOsNativeSdk.md` | Swift controller facade, UIKit host, native custom blocks/slash commands, public API, integration constraints | +| Document preview composable | `ui/CascadeDocumentPreview.kt` | `CascadeDocumentPreview()` (bounded immutable prefix, static non-scrollable `Column`, per-block preview resolution and latched measure/place/draw containment) | +| Document preview guide | `docs/DocumentPreview.md` | Public contract, list/grid integration, custom renderers, links/selection, fallback, accessibility, and containment boundaries | +| Preview mode recipes | `docs/recipes/PreviewMode.md` | Agent-oriented Compose Multiplatform and native iOS integration tutorials for preview grids that open persisted editable documents | +| Native iOS document preview facade | `editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewController.kt`, `CascadeDocumentPreviewConfiguration.kt`, `CascadeDocumentPreviewViewController.kt` | `CascadeDocumentPreviewController`, `CascadeDocumentPreviewConfiguration`, `makeViewController()` (bounded UIKit host, preserved-on-failure JSON reload, link/error callbacks, no editor runtime) | +| Native iOS preview-grid integration | `iosNativeSample/iosNativeSample/Editor/CascadeDocumentPreviewHost.swift`, `iosNativeSample/iosNativeSample/Screens/PreviewGalleryScreen.swift`, `iosNativeSample/iosNativeSample/Screens/PreviewDocumentEditorScreen.swift`, `iosNativeSample/iosNativeSample/Screens/Preview/` | `CascadeDocumentPreviewHost`, `PreviewGalleryScreen`, `PreviewDocumentLibrary`, `PreviewCardModel`, `PreviewDocumentEditorModel` (seeded/persisted JSON, one owned editor session per destination, debounced autosave plus background/exit flush, preview refresh after successful saves) | +| Native iOS SDK technical context | `docs/iOsNativeSdk.md` | Editable and preview Swift facades, bounded UIKit host lifecycle, native custom blocks/slash commands, runtime colors, public API, integration constraints | | Theme customization recipes | `docs/recipes/ThemeCustomization.md` | Agent-oriented Compose Multiplatform and native Swift tutorials for complete palettes and family-aware runtime appearance switching | | Native iOS color bridge | `editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/theme/CascadeEditorColorsBridge.kt` | Swift-visible `CascadeEditorColors` complete ARGB palette; controller snapshot mapping into core colors | | Native iOS publication runbook | `docs/iOsPublication.md` | Version/tag contract, GitHub/SwiftPM setup, immutable release sequence, recovery | @@ -61,6 +69,10 @@ Block-based editor (Craft/Notion-like) for Compose Multiplatform. Unidirectional | List utilities | `core/ListUtils.kt` | `renumberNumberedLists()` (internal) | | Outline utilities | `core/OutlineUtils.kt` | `shiftIndentation()` (internal), `resolveDragPayload()` (internal), `moveDragPayload()` (internal), `IndentationDirection` (internal) | | Registry | `registry/BlockRegistry.kt` | `BlockRegistry` | +| Document preview policy | `ui/CascadeDocumentPreviewConfig.kt`, `ui/ExperimentalCascadePreviewApi.kt` | `CascadeDocumentPreviewConfig` (`GridCard`/`Default`: 4 blocks × 3 lines at `textScale = 1f`; explicit nullable-limit `Unbounded`), `ExperimentalCascadePreviewApi` | +| Preview renderer extension | `registry/BlockPreviewRenderer.kt`, `registry/BlockPreviewScope.kt`, `ui/DefaultBlockPreviewScope.kt` | `BlockPreviewRenderer`, read-only `BlockPreviewScope` (`canOpenLinks` capability), separate `BlockRegistry` preview channel/fallback/revision | +| Static preview rich text | `richtext/PreviewAnnotatedString.kt`, `richtext/SpanMapper.kt` | `buildPreviewAnnotatedString()` (sentinel-free `AnnotatedString`, shared canonical span mapping, host-callback-only clickable links with no implicit URI opener) | +| Built-in preview renderers | `ui/renderers/PreviewTextBlockRenderer.kt`, `ui/renderers/PreviewTodoBlockRenderer.kt`, `ui/renderers/PreviewDividerBlockRenderer.kt`, `ui/renderers/UnknownBlockPreviewRenderer.kt` | Dedicated static text/todo/divider renderers plus bounded unknown/custom fallback; registered by `createEditorRegistry()` | | Descriptors | `registry/BlockDescriptor.kt` | `BlockDescriptor` | | Block render scope | `registry/BlockRenderScope.kt` | `BlockRenderScope` | | Scoped renderer interface | `registry/BlockRenderer.kt` | `ScopedBlockRenderer` | @@ -182,9 +194,9 @@ Unless a row uses an explicit module-root path such as `sample/src/...`, paths a ``` ┌─────────────────────────────────────────────────────────┐ -│ UI Layer (CascadeEditor, renderers, drag overlays) │ +│ UI Layer (CascadeEditor + preview, renderers) │ ├─────────────────────────────────────────────────────────┤ -│ Text State Layer (BlockTextStates, TextFieldState) │ +│ Text State Layer (editor only: BlockTextStates) │ ├─────────────────────────────────────────────────────────┤ │ State Layer (EditorState, EditorStateHolder) │ ├─────────────────────────────────────────────────────────┤ @@ -241,6 +253,12 @@ Unless a row uses an explicit module-root path such as `sample/src/...`, paths a **Public link API** — custom chrome reads `LocalLinkState` (`State?`) and `LocalLinkActions` (`LinkChromeActions?`) from inside `CascadeEditor`. These locals are provided regardless of `ToolbarSlot.Default`, `ToolbarSlot.Custom`, or `ToolbarSlot.None` so link UI does not require a new custom-toolbar lambda parameter. `LocalLinkState` is backed by lazy derived state over focused editor/text/span inputs. Read-only policy sets `LinkState.canLink` to false while preserving focused-block target and existing-link inspection metadata when available. The link API is split: `LinkActions` is the minimal target-based mutation surface (`applyLink(target, ...)`, `removeLink(target)`) used by popup sessions and any caller that already captured a target; `LinkChromeActions extends LinkActions` adds `currentTarget()` plus `applyLinkAtCurrentTarget(...)` / `removeLinkAtCurrentTarget()` sugar for chrome that reads live `LinkState`. `LocalLinkActions` exposes a gated `LinkChromeActions` facade over `LinkActionDispatcher`: when current `LinkState.canLink` or `EditorInteractionPolicy.canEditLinks` is false, apply still returns URL validation feedback but does not mutate, and remove no-ops. The default toolbar has a special link entry point controlled by `RichTextToolbarConfig.showLink`; editor-owned popup presentation is selected through `CascadeEditor(linkPopup = LinkPopupSlot.Default/Custom/None)`, and `LinkPopupController.open()` also gates on `canEditLinks` before creating a popup session. `LinkPopupSlot.Default` uses the foundation-only default `LinkPopup`, `Custom` receives the editor-managed `LinkPopupState` / `LinkPopupActions`, and `None` suppresses editor-owned popup state/UI. Link opening is configured separately through `CascadeEditor(onOpenLink)`: consumer callbacks receive normalized URLs and may throw, while the default `LocalUriHandler` path swallows platform opening failures. +**Document preview** — `CascadeDocumentPreview` is the experimental stateless Compose path for cards, feeds, grids, and search results. It renders an immutable `List` directly through dedicated `BlockPreviewRenderer` instances. `CascadeDocumentPreviewConfig.GridCard`/`Default` selects only the first four blocks and limits each text block to three lines; `Unbounded` removes those presentation bounds but remains eager and non-scrollable. A preview-local `Density` multiplies the host font scale by positive finite `textScale`, so every descendant `sp`, including custom renderer text, scales without directly scaling `dp` tokens or explicit host bounds; text layout still remeasures normally. Preview never normalizes or mutates the model, renumbers lists, appends an editor scaffold paragraph, or creates `EditorStateHolder`, `TextFieldState`, editor text-focus/IME state, history, slash, drag, mutation callbacks, or an internal scroll state. Enabled link annotations keep ordinary accessibility focus semantics. The host owns the outer list/grid, card sizing, and navigation. + +Static preview text is built by `buildPreviewAnnotatedString()` from exact visible text coordinates, without the editor sentinel. It shares canonical visual span mapping with `SpanMapper`, clamps malformed ranges, and installs clickable Compose link annotations only when links are enabled and the host supplies `onOpenLink`; otherwise links remain visual-only so an outer card can own the click. Link activation flows through the read-only `BlockPreviewScope`, uses `LinkUrlPolicy.validateStoredTarget()`, and delegates only to the host callback; there is no implicit `LocalUriHandler` fallback. The scope retains the original ordered input, including blocks hidden by presentation limits, and builds a first-ID-wins lookup lazily only when a custom renderer requests one. + +**Native iOS document preview** — `CascadeDocumentPreviewController` decodes JSON into immutable blocks and mounts the same bounded static preview in a transparent, non-scrolling UIKit controller. `CascadeDocumentPreviewConfiguration` maps Swift-friendly positive bounds, `Double` text scale, selection/link policy, dark mode, and crash policy to the core config; native scales that are non-finite, non-positive, or non-positive/non-finite after conversion to the core `Float` normalize to `1.0`. Parse failure preserves the mounted snapshot; link/error delivery uses explicit contained callbacks. Each native grid cell retains one controller/host for its lifetime while Swift owns sizing, scrolling, navigation, persistence, and the separate editable `CascadeEditorController`. Native custom editor registrations are not shared: editor UIKit/SwiftUI renderers never mount in preview, unknown/custom JSON uses the generic bounded fallback, and no Swift custom preview-provider API exists yet. + ## State Management **EditorState** — immutable snapshot: `blocks`, `focusedBlockId`, `selectedBlockIds`, `dragState`, `slashCommandState`. Cursor position is NOT in EditorState — it lives in `TextFieldState` managed by `BlockTextStates`. **Invariant:** `focusedBlockId` and `selectedBlockIds` are mutually exclusive — enforced by reducers, not UI code. Selection reducers clear focus; focus reducers (with non-null target) clear selection. `ClearFocus` and `ClearSelection` are orthogonal and do not enforce this on each other. @@ -279,6 +297,8 @@ All state changes go through `EditorAction.reduce(state) → newState`. **Standard flow:** User Input → `BlockCallbacks` → `EditorAction` → `dispatch()` → `reduce()` → recomposition. +**Document preview flow:** Immutable `List` → bounded source-order prefix → dedicated `BlockRegistry.getPreviewRenderer(block.type)` → static built-in/custom/fallback renderer → non-scrollable `Column`. Preview registry mutations increment the snapshot-backed registry revision, so an already-mounted fallback can resolve to a newly registered preview renderer without crossing into the editor-renderer channel. A valid built-in link activation follows `LinkAnnotation.Clickable` → `BlockPreviewScope.openLink()` → stored-target validation → optional host `onOpenLink`; it never enters editor link state/actions or an implicit platform opener. + **Text operations (merge/split):** `BlockCallbacks` performs runtime transfer first (`BlockTextStates` + `BlockSpanStates`) and then dispatches block-structure actions. `onEnter` returns without mutation while block selection is active; `BlockType.Code` blocks intercept Enter before the empty-list / split branches and never split — ranged Enter replaces the selection with a single `\n`, empty Code converts to `Paragraph` (preserving `BlockId`), trailing-blank-line Enter (cursor at end + visible text ends with `\n`) drops the trailing newline and dispatches `SplitBlock` with `sourceBlockText = trimmedText` / `newBlockText = ""` to inject a fresh `Paragraph` below, otherwise Enter inserts `\n` at the collapsed cursor — all four branches dispatch through `runStructuralMutation` and form one `StructuralEntry`. For non-Code blocks `onEnter` routes empty `BulletList`, `NumberedList`, and `Todo` blocks at depth greater than `0` to `IndentBackward` before splitting. Empty root `BulletList` and `NumberedList` blocks exit to `Paragraph`; empty root `Todo` blocks keep the normal split-continuation path and create a new unchecked `Todo`. Split paths generate `newBlockId` only when splitting and pass runtime payload (`newBlockSpans`, `sourceBlockText`, `sourceBlockSpans`) into `SplitBlock` for deterministic runtime/snapshot alignment. `onBackspaceAtStart` returns without mutation while block selection is active, then routes any supported block at depth greater than `0` to `IndentBackward` before root-level un-list or merge handling. Merge flows use captured pre-merge target length from `BlockTextStates.mergeInto(...)` to shift source spans exactly once, then sync merged content to snapshot via `UpdateBlockContent` before dispatching `DeleteBlock`. `SplitBlock` and `MergeBlocks` reducers split/merge snapshot spans using `SpanAlgorithms` for snapshot consistency. `SplitBlock` carries supported source indentation to supported continuation blocks; merge keeps the target block's attributes authoritative. **Live typing history:** `TextBlockField` observes committed `TextFieldState` snapshots directly. For user text edits, the pipeline is: slash observer -> span maintenance -> checkpoint-based history capture/coalescing -> list auto-detect. Programmatic commits bypass that capture path by re-anchoring the local tracker from a full checkpoint. Replay also bypasses capture, but block-local undo/redo now re-anchors the registered tracker directly from the replay payload while structural replay recreates trackers by clearing and rebuilding runtime holders, avoiding replay-time full-document checkpoint churn inside `TextBlockField`. Structural commands also terminate live typing explicitly through the holder transaction wrapper before they mutate document shape or block type, so sequences like `type -> split` stay in separate undo steps. In read-only mode the text field remains mounted and selectable, but editor-owned text input mutation paths do not construct slash observers, mark paste batches, break typing batches for Enter/backspace, execute editor undo/redo shortcuts, apply formatting shortcuts, insert code-block newlines, split/convert blocks from Enter, execute slash commands, or convert list triggers. @@ -295,7 +315,9 @@ All state changes go through `EditorAction.reduce(state) → newState`. ## Registry System -**BlockRegistry** — maps `typeId` string to `BlockDescriptor` (metadata + factory) and `BlockRenderer` (UI). Use `registry.search(query)` for slash command filtering. Use `registry.getRenderer(blockType)` for rendering (includes unknown-block fallback) or `registry.getRenderer(typeId)` for direct lookup. `setUnknownBlockRenderer()` registers a fallback renderer for `UnknownBlockType` blocks. `createEditorRegistry()` pre-registers all built-in types: `TodoBlockRenderer` for "todo", `TextBlockRenderer` for all other text-supporting types, and `UnknownBlockRenderer` as the unknown-block fallback. All text-editing renderers share the `TextBlockField` composable for text input, spans, and focus. +**BlockRegistry** — maps `typeId` strings through independent channels for `BlockDescriptor` metadata/factories, editor `BlockRenderer` UI, and stateless `BlockPreviewRenderer` UI. Use `registry.search(query)` for slash command filtering. Use `registry.getRenderer(blockType)` for editor rendering (including the editor unknown-block fallback) or `registry.getRenderer(typeId)` for direct editor lookup. Preview code uses only `getPreviewRenderer(blockType)`, whose exact renderer and `setUnknownBlockPreviewRenderer()` fallback are separate from the editor map and `setUnknownBlockRenderer()`. An editor renderer is never an automatic preview fallback, including for custom types. Every registration or fallback replacement increments the shared snapshot-backed revision so mounted editor and preview consumers can re-resolve in-place registry changes; configure a registry on the UI thread before sharing it. + +`createEditorRegistry()` pre-registers all built-in editor and preview renderers. Editor text-capable blocks use `TextBlockRenderer`/`TodoBlockRenderer` and share `TextBlockField` for text input, spans, and focus. Preview text/todo/divider renderers reuse extracted static visual primitives and `UnknownBlockPreviewRenderer` provides a bounded text-preserving/localized fallback without interpreting custom payloads or invoking editor renderers. **BlockRenderer** — `Render(block, isSelected, isFocused, modifier, callbacks)`. Property `handlesSelectionVisual` (default `false`) opts out of the wrapper-level selection overlay; when `true` the renderer is fully responsible for its own selection chrome using `isSelected`. @@ -308,11 +330,13 @@ All state changes go through `EditorAction.reduce(state) → newState`. - **`internal`** for implementation details, **`public`** for API surface - **New actions** must be a data class/object extending `EditorAction` with a `reduce()` override - **Renderers** access text via `LocalBlockTextStates.current`, never from `BlockContent` directly during editing +- **Preview renderers** read immutable `BlockContent` snapshots directly through `BlockPreviewScope`; they must remain stateless, expose no mutation/focus/history surface, never mount editor renderers or text fields, and leave vertical scrolling to the host +- **Native preview hosts** retain one `CascadeDocumentPreviewController` and one returned view controller per live bounded cell; update content/configuration through the controller, let the outer Swift collection own scrolling/navigation, and release both with the cell - **High-frequency updates** (drag position, scroll) use `mutableFloatStateOf` locally, NOT in `EditorState` - **Performance**: prefer `graphicsLayer { }` lambdas over Modifier params for draw-phase-only changes (e.g., alpha, translationY) - **Drag gesture** lives on the Box wrapper, NOT on LazyColumn items (survives recycling) - **Auto-scroll** uses `dispatchRawDelta` to avoid MutatorMutex contention with gesture scroll -- **Crash containment has two regimes** — UI-runtime guards (per-block measure/draw via `guardedBlockRender`, span `OutputTransformation`) are policy-gated by `CascadeEditorConfig.crashPolicy` (`Rethrow` in tests/debug, `ContainAndReport` in release) and route through `guarded()`; the serialization layer (`loadFromJson`/`loadFromHtml` string entry points, HTML decode) is always-contain-and-warn (returns `DocumentParseFailed`/`InputLimitExceeded` warning lists, not policy-gated). Compose forbids `try/catch` around `@Composable` calls, so composition-phase renderer throws are not containable in-tree (host trust boundary for custom renderers) +- **Crash containment has two regimes** — UI-runtime guards (editor block measure/draw via `guardedBlockRender`, preview block measure/place/draw via a latched outer guard, and span `OutputTransformation`) are policy-gated by the surface config (`Rethrow` in tests/debug, `ContainAndReport` in release) and route through `guarded()`; the serialization layer (`loadFromJson`/`loadFromHtml` string entry points, HTML decode) is always-contain-and-warn (returns `DocumentParseFailed`/`InputLimitExceeded` warning lists, not policy-gated). Compose forbids `try/catch` around `@Composable` calls, so composition-phase renderer throws are not containable in-tree (host trust boundary for custom renderers) - **Tests** go in `editor/src/commonTest/` (Compose UI tests in `editor/src/desktopTest/`). Run: `./gradlew :editor:allTests` - **Public-API snapshots** — binary-compatibility-validator guards `:editor` (and `:editor-ios-sdk`): JVM/Android dumps in `editor/api/{desktop,android}/`, klib (iOS/wasm) dumps in `editor/api/editor.klib.api`. `apiCheck` runs with `check` and fails on unbaselined public-surface changes; after an intentional API change run `./gradlew :editor:apiDump :editor-ios-sdk:apiDump` and commit the diff - **Native iOS binary distribution** — `:editor-ios-sdk` ships as a dynamic iOS 16+ XCFramework for arm64 devices and Apple Silicon simulators; Compose Multiplatform 1.11.1 has no `iosX64` dependency variants for Intel simulator builds. The framework owns Compose resources, privacy metadata, notices, and dSYMs; its bundle/version metadata and `CascadeEditorSdk.version` derive from `VERSION_NAME`. External consumers use the separately tagged `linreal/cascade-editor-ios` Swift package and must not add manual framework/resource copy phases. Release packaging serializes native test and release link stages to stay within standard macOS runner memory; the workflow repeats this prebuild guard so immutable tags can consume workflow-only fixes from `main`. Release CI preflights package-repository push permission and `main`, uses portable `simctl bootstatus -b`, and uploads evidence only after a publication ZIP exists so earlier failure signals remain authoritative @@ -327,6 +351,8 @@ All state changes go through `EditorAction.reduce(state) → newState`. | Selection (single, multi, range) | Done | Actions done with focus/selection mutual exclusivity invariant; UI triggers partial (`onClick` is a stub); wrapper-level selection overlay with `handlesSelectionVisual` opt-out | | Drag & drop (gesture, preview, indicator, auto-scroll) | Done | Subtree-aware payloads, depth-aware hover, lane-aligned indicator, preview indentation, and payload badge | | Block registry & search | Done | | +| Document preview (experimental Compose API) | Experimental | Stateless bounded container, proportional all-`sp` text scaling, dedicated renderer channel/fallback, static built-in visuals/rich text, host-only link callback, optional selection, crash guards, Compose gallery, native Swift grid/editor integration, and regression coverage. Stabilization still requires the validation evidence listed below | +| Native iOS document preview facade | Experimental | `CascadeDocumentPreviewController`/`Configuration`, Swift-facing text scale, bounded transparent UIKit host, preserved-on-parse-failure JSON reload, link/error callbacks, no editor runtime, and seeded Swift grid-to-editor sample. Native custom preview providers are not exposed yet | | TextBlockRenderer | Done | All text-supporting types except todo | | TextBlockField (shared) | Done | Extracted text editing composable used by all text renderers | | Heading font sizes | Done | No bold weight yet | @@ -379,6 +405,8 @@ All state changes go through `EditorAction.reduce(state) → newState`. | # | Area | Constraint | |---|------|-----------| | 1 | **History Capture Boundary** | Direct raw `EditorStateHolder.dispatch(...)` still bypasses history by contract. Built-in structural commands must route through the holder transaction helpers (or another history-aware boundary) if they need undo/redo coverage. | +| 2 | **Preview Stabilization Evidence** | Cross-platform manual QA for link activation, selection/clipboard, and accessibility plus an Android release benchmark baseline remain outstanding before the experimental preview API can be considered for stabilization. | +| 3 | **Native Preview Extensibility** | The Swift facade preserves unknown/custom block JSON but currently renders it through the generic bounded fallback. Native editor block renderers never mount in preview and no native custom preview-provider API exists yet. | ## Testing @@ -398,6 +426,15 @@ All state changes go through `EditorAction.reduce(state) → newState`. | `SlashCommandEditorHostTest.kt` | replaceQueryText (removal, replacement, span preservation, snapshot sync), updateAnchorText, replaceAnchorBlock (id preservation, focus), insertBlockAfterAnchor (ordering, focus), focusBlock, closeMenu, graceful no-ops for missing anchors | | `SlashCommandTextObserverTest.kt` | Session opening (start/middle/empty, non-slash, deletion, replacement), updating (progressive, spaces), closing (slash deletion, cursor outside range, focus lost), programmatic changes (skip, preserve, remove), paste/multi-char excluded, notifySessionClosed, range shifting (insert/delete before slash), within-range edits, after-range cursor, identical no-op, successive open-after-close (~30 tests) | | `BlockRegistryTest.kt` | Descriptor search, block creation, slash metadata exposure, behavior policies per built-in type | +| `BlockPreviewRegistryTest.kt` | Independent editor/preview channels, exact/fallback identity, snapshot revision changes, first-duplicate scope lookup, link capability/policy, and missing-opener no-op | +| `ui/CascadeDocumentPreviewConfigTest.kt` | Grid-card defaults, nullable unbounded limits, positive-limit/text-scale validation, and config-copy policy preservation | +| `ui/CascadeDocumentPreviewLogicTest.kt` | Ordered prefix limiting plus no-copy behavior for unbounded and already-within-budget inputs | +| `PreviewAnnotatedStringTest.kt` | Sentinel-free static rich-text parity, overlap/decoration composition, range clamping, link styling/annotation/callback behavior, and input immutability | +| `BuiltInPreviewVisualTest.kt` | Static built-in style/geometry helpers and dedicated built-in preview registration coverage | +| `ui/CascadeDocumentPreviewStaticSemanticsTest.kt` (desktopTest) | Static non-editable text and checked-todo semantics without a toggle action | +| `ui/CascadeDocumentPreviewTest.kt` (desktopTest) | Empty/bounded rendering, duplicate IDs, no edit/scroll semantics, registry live replacement, latched measure/place/draw and reporter containment, pointer-level link/card arbitration, visual-only links, selection/line bounds, unknown fallback, and fixed-height outer-grid integration | +| `editor-ios-sdk/.../CascadeDocumentPreviewControllerTest.kt` (iosSimulatorArm64Test) | Swift-facing defaults/config mapping, safe limit/text-scale normalization, JSON replacement and parse-failure preservation, runtime theme/config updates, link/error callback containment, and unknown/custom JSON preservation | +| `editor-ios-sdk/.../GoldenParityTest.kt` (iosSimulatorArm64Test) | Native/KMP fixture parity plus canonical round trips for all six persisted preview-gallery seed documents | | `BlockTest.kt` | Core block creation, `BlockAttributes` defaults/copying/range validation, indentation support matrix, `NumberedList` type validation | | `ListUtilsTest.kt` | `renumberNumberedLists`: outline-aware depth/parent sequences, same-depth breaks, parent continuation across descendants, referential equality | | `ListAutoDetectObserverTest.kt` | Bullet trigger (dash+space), numbered trigger (N.+space), no-trigger guards (mid-text, already-list, paste, programmatic, zero, deletion, replacement) | diff --git a/CascadeDocumentPreview Implementation Plan.md b/CascadeDocumentPreview Implementation Plan.md new file mode 100644 index 0000000..96d249b --- /dev/null +++ b/CascadeDocumentPreview Implementation Plan.md @@ -0,0 +1,1338 @@ +# CascadeDocumentPreview — Detailed Implementation Plan + + +**Target repository:** `CascadeEditor` +**Primary source root:** `editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/` +**Primary test roots:** `editor/src/commonTest/` and `editor/src/desktopTest/` + +## Executive Summary + +`CascadeDocumentPreview` should be a stateless, non-editable document renderer for bounded previews such as note cards, search results, feeds, history screens, and permission-independent content summaries. + +It must not be implemented as a thin wrapper around `CascadeEditor(readOnly = true)`. Read-only mode preserves the full editor runtime because it intentionally supports focus, text selection, link hit-testing, and a transition back to editing. A preview has a different performance contract: + +- no `EditorStateHolder`; +- no `BlockTextStates` or `BlockSpanStates`; +- no `TextFieldState` or `BasicTextField`; +- no history trackers or full-document checkpoints; +- no `snapshotFlow` observers; +- no focus, IME, keyboard, slash-command, toolbar, link-editing, drag, block-selection, or auto-scroll infrastructure; +- no nested scroll container; +- no document mutation or normalization; +- static rendering directly from immutable `Block` snapshots. + +The recommended implementation uses `BasicText` plus an `AnnotatedString` for built-in text blocks, shares pure visual mapping and built-in block chrome with the editor where practical, and introduces an opt-in preview-renderer contract for custom block types. Existing editor renderers must never be invoked as an automatic preview fallback because they may depend on editor-only CompositionLocals, mutable callbacks, platform views, or `BlockRenderScope`. + +### Recommended delivery shape + +| Delivery level | Scope | +| ------------------------------- | ------------------------------------------------------------------------------------ | +| Built-in vertical slice | Built-in blocks, bounded static layout, no custom preview renderer | +| Production-ready public feature | Public API, built-ins, links, accessibility contract, safe fallback, tests, docs | +| Full custom-block preview story | Public extension seam, custom sample, platform-view guidance, compatibility coverage | +| Android performance proof | Macrobenchmark module/scenarios and trace review | +| Native Swift/UIKit facade | Preview controller/view and native custom-preview adapter | + +The recommended v1 release is the production-ready public feature plus an Android performance proof. Native Swift/UIKit exposure can be a follow-up unless it is a release requirement. + +--- + +## Problem Statement + +A grid containing 6–10 read-only `CascadeEditor` instances may be acceptable for a small MVP, but it retains substantial editor machinery: + +- `CascadeEditor` creates a `LazyColumn` and editor-level runtime objects for every card. +- Every visible text block creates a `TextFieldState`, `BasicTextField`, span transformation, focus/key handler, history tracker, and text/selection observer. +- Every text history tracker initializes from a full-document checkpoint. +- Read-only mode disables mutation and several gesture systems, but it does not become a static renderer. +- The internal vertical `LazyColumn` remains scrollable, creating a same-direction nested scrolling problem inside a `LazyVerticalGrid`. + +The feature should provide the same document-model fidelity and substantially the same built-in visual language without paying the text-editing cost. + +## Goals + +- Render the beginning of a Cascade document efficiently in bounded card-sized surfaces. +- Match the editor’s built-in typography, colors, block spacing, indentation, list markers, quote styling, code styling, todo state, divider styling, and rich-text span behavior. +- Render directly from `List` without creating or binding editor runtime state. +- Avoid any internally scrollable container. +- Support a predictable block and text-line budget. +- Support optional link opening without enabling link editing or text focus. +- Define explicit behavior for text selection and copy. +- Contain renderer failures using the existing crash policy model. +- Provide an opt-in, stateless extension seam for custom preview renderers. +- Preserve source and binary compatibility for existing `BlockRenderer` and `ScopedBlockRenderer` consumers. +- Work from shared `commonMain` code across Android, iOS, desktop, and wasm. +- Include a representative grid sample and objective performance validation. + +## Non-Goals + +- Replacing `CascadeEditor(readOnly = true)`. +- Transitioning an already-mounted preview instance directly into editing. +- Supporting cursor placement, IME input, keyboard shortcuts, toolbar state, or editor focus. +- Supporting block selection, drag-and-drop, slash commands, undo/redo, or link editing. +- Automatically generating a semantic summary of a note. +- Persisting or mutating a truncated preview document. +- Acting as a virtualized full-document reader. +- Owning application navigation or whole-card click behavior. +- Automatically loading images, embeds, or remote media. +- Automatically reusing arbitrary editor renderers in preview mode. +- Providing the native Swift/UIKit facade in the first implementation unless explicitly pulled into scope. + +--- + +## Current Architecture Assessment + +### Reusable assets + +- `Block`, `BlockType`, `BlockContent`, `TextSpan`, and `SpanStyle` are immutable document-model inputs. +- `SpanMapper` already converts domain span styles into Compose `SpanStyle` runs. +- `CommentModel.rememberCommentAnnotatedString()` proves that Cascade text plus spans can be rendered statically without an editor. +- `resolveOrderedListPrefixStyles()` and `formatOrderedListPrefix()` are pure or effectively pure list-visual helpers. +- `blockIndentationInset()` is a pure indentation geometry helper. +- `CascadeEditorTheme`, colors, typography, dimensions, and localization types can be provided through the existing theme CompositionLocals. +- The existing crash-handling primitive can protect per-block preview rendering. + +### Coupling that must be removed or bypassed + +- `TextBlockRenderer` combines block chrome with `TextBlockField`, which mounts editing infrastructure. +- `TodoBlockRenderer` combines the static todo visual with an interactive `toggleable`. +- `BlockRenderer` receives editor callbacks and may assume editor state exists. +- `ScopedBlockRenderer` deliberately requires a live `BlockRenderScope` and may mutate document state. +- `BlockRegistry` currently has one renderer map intended for editor rendering. +- `SpanMapper` applies styles to a `TextFieldBuffer` with the editor’s leading sentinel offset; preview text has no sentinel. +- Existing editor indentation and font transitions animate even when a preview should be static and cheap. + +### Architectural conclusion + +The preview should share pure mapping and visual primitives, not editor runtime components. Where sharing would introduce `mode` branches throughout editor code, prefer a small dedicated preview renderer that composes extracted primitives. + +--- + +## Locked Design Decisions + +1. **The public input is immutable document data.** + `CascadeDocumentPreview` accepts `List`, not `EditorStateHolder`, `BlockTextStates`, or `BlockSpanStates`. + +2. **The component is non-scrollable.** + It uses a bounded static layout and never installs `verticalScroll`, `LazyColumn`, or a nested-scroll connection. The host owns the grid/list scroll and card height. + +3. **The preview never modifies its input.** + It must not append the editor’s trailing empty paragraph, normalize indentation, renumber lists, or write back truncated text. + +4. **Preview limits are presentation limits.** + A block limit selects an ordered prefix of the input list for rendering. A line limit controls `BasicText` measurement and overflow. Neither produces a new persistence model. + +5. **Built-in text uses `BasicText`, not `BasicTextField`.** + There is no sentinel, `TextFieldState`, focus requester, input transformation, key handler, or IME configuration. + +6. **No animation by default.** + Font size, todo state, and indentation are rendered directly at their target values. Grid previews should not create animation state when cells enter composition. + +7. **Editor renderers are not the preview fallback.** + Existing `BlockRenderer` instances are never invoked automatically from `CascadeDocumentPreview`. + +8. **Custom preview rendering is opt-in.** + A new sibling contract is added instead of changing `BlockRenderer.Render(...)` or adding a render-mode parameter. + +9. **One `BlockRegistry` may hold both renderer kinds.** + The existing registry gains a separate preview-renderer map and fallback. This lets consumers hoist one registry for an editor and its previews without conflating the contracts. + +10. **Link opening is the only built-in block interaction.** + Whole-card click/navigation stays outside the component. + +11. **Text selection is off by default.** + Preview cards are primarily navigational surfaces. An opt-in selection mode may use `SelectionContainer`, subject to platform QA. + +12. **Crash containment applies per block.** + A failing custom preview renderer degrades to a safe fallback and reports through the configured error reporter. + +13. **The first release should be experimental if the custom renderer API ships.** + Use an `ExperimentalCascadePreviewApi` annotation for the preview component, config, and renderer extension seam for at least one minor release. + +--- + +## Product Decisions to Confirm + +> [!warning] Default preview budget +> The exact default `maxBlocks` and `maxLinesPerTextBlock` values are product/design decisions. The implementation should expose named tokens or a `GridCard` preset rather than scattering literal values. The sample grid and benchmark should validate the final defaults. + +> [!warning] Text selection +> The plan assumes selection/copy is disabled by default and optional. If selection across multiple blocks is a v1 requirement, add native clipboard QA on Android, iOS, desktop, and wasm to the release gate. + +> [!warning] Native iOS SDK +> The plan assumes the Compose Multiplatform API ships before a Swift/UIKit facade. If native Swift consumers must instantiate previews directly, include the optional native SDK task in the release scope. + +> [!warning] Interactive custom blocks +> A preview renderer is intentionally stateless. If a product expects table-cell controls, media playback, or other interactive custom content inside note cards, that requires a separate capability design and should not be smuggled through editor callbacks. + +--- + +## Proposed Public API + +The exact names can change during API review, but the responsibility boundaries should remain stable. + +```kotlin +@ExperimentalCascadePreviewApi +@Composable +public fun CascadeDocumentPreview( + blocks: List, + modifier: Modifier = Modifier, + registry: BlockRegistry = remember { createEditorRegistry() }, + theme: CascadeEditorTheme = CascadeEditorTheme.light(), + blockStrings: CascadeEditorBlockStrings = CascadeEditorBlockStrings.default(), + config: CascadeDocumentPreviewConfig = CascadeDocumentPreviewConfig.Default, + onOpenLink: ((String) -> Unit)? = null, +) +``` + +```kotlin +@ExperimentalCascadePreviewApi +@Immutable +public data class CascadeDocumentPreviewConfig( + val maxBlocks: Int, + val maxLinesPerTextBlock: Int, + val textOverflow: TextOverflow, + val textSelectionEnabled: Boolean, + val linksEnabled: Boolean, + val crashPolicy: CrashPolicy, + val onInternalError: CascadeErrorReporter?, +) +``` + +Recommended config presets: + +- `Default` or `GridCard`: bounded, ellipsized, selection disabled, links enabled. +- `Unbounded`: explicit opt-in for non-grid/static document surfaces; still non-scrollable. + +The component KDoc must clearly state that consumers rendering many previews should hoist and share a stable registry, theme, and localization objects. + +### Custom preview renderer seam + +```kotlin +@ExperimentalCascadePreviewApi +public interface BlockPreviewRenderer { + @Composable + public fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) +} +``` + +```kotlin +@ExperimentalCascadePreviewApi +@Stable +public interface BlockPreviewScope { + public val blocks: List + public val config: CascadeDocumentPreviewConfig + public fun getBlock(blockId: BlockId): Block? + public fun openLink(target: String) +} +``` + +`BlockPreviewScope` is read-only. It must not expose `dispatch`, `updateBlock`, focus, history, text states, span states, or editor configuration. + +### Registry additions + +- `registerPreviewRenderer(typeId, renderer)` +- `getPreviewRenderer(blockType)` +- `setUnknownBlockPreviewRenderer(renderer)` +- Optional convenience overload registering descriptor, editor renderer, and preview renderer together. + +All preview-registry mutations increment the existing registry revision so a visible fallback can swap to the newly registered renderer without waiting for unrelated recomposition. + +--- + +## Data Flow + +```mermaid +flowchart LR + A["Immutable List"] --> B["Apply bounded prefix"] + B --> C["Precompute ordered-list visual context"] + C --> D["Resolve BlockPreviewRenderer by typeId"] + D --> E["Render static block chrome"] + E --> F["BasicText + AnnotatedString"] + F --> G["Draw / optional link callback"] +``` + +Explicitly absent from the flow: + +```text +EditorStateHolder + ├── BlockTextStates / TextFieldState + ├── BlockSpanStates runtime synchronization + ├── history checkpoints + ├── focus / keyboard / IME + ├── slash / toolbar / link editing + ├── block gestures / drag + └── LazyColumn / nested scrolling +``` + +--- + +## Proposed File Map + +### Create + +- `editor/src/commonMain/.../ui/CascadeDocumentPreview.kt` +- `editor/src/commonMain/.../ui/CascadeDocumentPreviewConfig.kt` +- `editor/src/commonMain/.../ui/ExperimentalCascadePreviewApi.kt` +- `editor/src/commonMain/.../registry/BlockPreviewRenderer.kt` +- `editor/src/commonMain/.../registry/BlockPreviewScope.kt` +- `editor/src/commonMain/.../ui/DefaultBlockPreviewScope.kt` +- `editor/src/commonMain/.../richtext/PreviewAnnotatedString.kt` +- `editor/src/commonMain/.../ui/renderers/PreviewTextBlockRenderer.kt` +- `editor/src/commonMain/.../ui/renderers/PreviewTodoBlockRenderer.kt` +- `editor/src/commonMain/.../ui/renderers/PreviewDividerBlockRenderer.kt` +- `editor/src/commonMain/.../ui/renderers/UnknownBlockPreviewRenderer.kt` +- `editor/src/commonTest/.../PreviewAnnotatedStringTest.kt` +- `editor/src/commonTest/.../BlockPreviewRegistryTest.kt` +- `editor/src/commonTest/.../CascadeDocumentPreviewConfigTest.kt` +- `editor/src/desktopTest/.../ui/CascadeDocumentPreviewTest.kt` +- `docs/DocumentPreview.md` + +### Modify + +- `editor/src/commonMain/.../registry/BlockRegistry.kt` +- `editor/src/commonMain/.../ui/EditorRegistry.kt` +- `editor/src/commonMain/.../richtext/SpanMapper.kt` +- `editor/src/commonMain/.../ui/renderers/TextBlockRenderer.kt` +- `editor/src/commonMain/.../ui/renderers/TodoBlockRenderer.kt` +- Potentially `editor/src/commonMain/.../ui/renderers/DividerBlockRenderer.kt` +- `ARCHITECTURE.md` +- `README.md` +- Public API snapshot files under `editor/api/` +- Sample navigation and new preview sample files under `sample/src/commonMain/...` + +The final implementation may combine small preview renderer files if cohesion is better, but editor runtime logic and preview rendering must remain visibly separated. + +--- + +# Implementation Tasks + +## Task 1 — Lock the preview contract and representative fixtures + + +**Dependencies:** None + +### Context + +The largest risk is scope drift: a “preview” can accidentally become another read-only editor, a full document reader, or an application-specific note-card component. Lock the behavioral contract before extracting rendering code. + +### Scope + +- Create `CascadeDocumentPreviewConfig.kt`. +- Create `ExperimentalCascadePreviewApi.kt`. +- Create common-test fixtures representing built-in block and span combinations. +- Add the public API proposal to `docs/DocumentPreview.md` as an initial contract section. + +### Requirements + +- Define bounded preview configuration and validation. +- Reject invalid limits deterministically, such as non-positive block or line counts, unless a named unbounded sentinel/preset is used. +- State that the preview: + - is stateless; + - is non-scrollable; + - never mutates blocks; + - never appends a trailing block; + - is not a permission boundary; + - does not replace editor read-only mode. +- Create at least three reusable fixtures: + - `simplePreviewDocument`: heading, paragraph, todo, divider; + - `spanHeavyPreviewDocument`: every built-in span style plus overlaps; + - `structuralPreviewDocument`: nested bullet/numbered/todo outline, quote, multiline code, unknown block. + +### Restrictions and considerations + +- Do not introduce `EditorStateHolder` into preview types. +- Do not reuse `CascadeEditorConfig`; preview behavior is not editor interaction policy. +- Do not define automatic content summarization. +- Do not make the note-card container or navigation callback part of the library API. +- Keep exact numeric defaults in named constants or presets so they can be tuned after benchmark/design review. + +### Tests + +- Given default config, when inspected, then limits and interaction defaults match the documented contract. +- Given every invalid limit, when config is constructed, then validation fails with an actionable message. +- Given `Unbounded`, when constructed, then it is valid and does not rely on magic negative numbers. +- Given `copy(...)`, when unrelated fields change, then crash and interaction fields are preserved. + +### Definition of done + +- [ ] Config and opt-in annotation compile in `commonMain`. +- [ ] Config tests pass. +- [ ] Contract explicitly distinguishes preview from read-only editor mode. +- [ ] Default budget remains centralized. +- [ ] No editor runtime dependency has been introduced. + +--- + +## Task 2 — Add the custom preview renderer contract and registry channel + + +**Dependencies:** Task 1 + +### Context + +Existing editor renderers are unsafe as a generic preview fallback. A separate extension seam preserves existing APIs while allowing custom blocks to provide lightweight representations. + +### Scope + +- Create `registry/BlockPreviewRenderer.kt`. +- Create `registry/BlockPreviewScope.kt`. +- Modify `registry/BlockRegistry.kt`. +- Create `ui/DefaultBlockPreviewScope.kt`. +- Add `BlockPreviewRegistryTest.kt`. + +### Requirements + +- Store preview renderers separately from editor renderers. +- Resolve preview renderers by `BlockType.typeId`. +- Support an unknown/custom preview fallback. +- Increment the registry revision on preview registration and fallback changes. +- Keep existing descriptor and editor-renderer behavior byte-for-byte unchanged. +- Provide a read-only scope with: + - original ordered block list; + - current preview config; + - block lookup; + - policy-aware link opening. +- `openLink()` must be a no-op when links are disabled or no opener is supplied. + +### Restrictions and considerations + +- Do not modify the existing `BlockRenderer.Render(...)` signature. +- Do not add a mode flag to `BlockRenderer`. +- Do not call an editor renderer when a preview renderer is absent. +- Do not expose mutable collections from the scope. +- Do not expose editor callbacks, `EditorAction`, focus, or state holders. +- Preserve registry mutation semantics and snapshot observation. +- Document that registries should be configured before being shared across threads; mutation remains a UI-thread-oriented operation. + +### Tests + +- Given an empty registry, when a preview renderer is requested, then null/fallback behavior is correct. +- Given a registered preview renderer, when resolved by matching type ID, then the same instance is returned. +- Given an editor renderer only, when preview resolution runs, then the editor renderer is not returned. +- Given an unknown type and fallback, when resolved, then the fallback is returned. +- Given a preview registration after initial lookup, when revision is observed, then revision increases. +- Given links disabled, when custom scope calls `openLink`, then the host callback is not invoked. +- Given a missing block ID, when `getBlock` is called, then null is returned without failure. + +### Definition of done + +- [ ] Existing renderer interfaces remain source compatible. +- [ ] Registry tests cover separate channels and revision behavior. +- [ ] Preview scope has no mutation surface. +- [ ] KDoc explains why editor renderers are not automatic fallbacks. +- [ ] `apiCheck` failure is expected only for the intentional additive API. + +--- + +## Task 3 — Extract shared rich-text visual mapping and add an AnnotatedString adapter + + +**Dependencies:** Task 1 +**Can parallelize with:** Task 2 + +### Context + +`SpanMapper` already owns the editor’s domain-to-Compose visual rules, including overlap handling, link decoration, inline-code background, and base-decoration combination. Preview rendering needs the same mapping without a `TextFieldBuffer` or sentinel offset. + +### Scope + +- Modify `richtext/SpanMapper.kt`. +- Create `richtext/PreviewAnnotatedString.kt`. +- Create `PreviewAnnotatedStringTest.kt`. +- Refactor the sample comment mapper later to use public/domain logic if appropriate; do not couple the editor module to the sample. + +### Requirements + +- Preserve one canonical mapping from Cascade `SpanStyle` to Compose span runs. +- Keep field-specific sentinel offset handling only in the text-field adapter. +- Add a pure builder that: + - accepts visible text and document spans; + - clamps ranges safely; + - drops empty ranges; + - preserves overlapping compatible styles; + - preserves combined underline + strikethrough behavior; + - styles links exactly as the editor does; + - drops unsupported custom span visuals consistently with the editor; + - never mutates the input span list. +- Ensure code blocks can bypass span rendering because `supportsSpans = false`. +- Cache the resulting `AnnotatedString` with stable `remember` keys at the composable call site. + +### Restrictions and considerations + +- Do not duplicate the style `when` mapping in a second production location. +- Do not apply the editor’s leading zero-width-space offset to preview ranges. +- Do not trust incoming span ranges to be normalized. +- Preserve `CrashPolicy` handling around any consumer-provided/custom mapping if that becomes extensible later. +- Confirm current highlight behavior: preview must match the editor’s themed highlight visual rather than silently adopting a different color policy from the comments sample. +- Avoid regular expressions and avoid work proportional to total document size; mapping should be local to the rendered text block. + +### Tests + +- One test for every built-in span style. +- Range clamping for negative, oversized, reversed, and empty ranges. +- Bold + italic overlap. +- Underline + strikethrough overlap produces combined decoration. +- Link + explicit underline does not produce an unintended double-decoration region. +- Base todo strikethrough remains visible under a styled run. +- Unsupported custom span is ignored without affecting supported spans. +- Empty text and empty spans return a valid empty `AnnotatedString`. +- Input lists remain structurally unchanged. +- Output contains no sentinel character. + +### Definition of done + +- [ ] Editor `OutputTransformation` behavior remains unchanged. +- [ ] Preview adapter uses the same canonical mapped runs. +- [ ] Span tests cover parity and malformed ranges. +- [ ] Existing `SpanMapperTest` passes unchanged or with parity-focused additions. +- [ ] No preview code depends on `TextFieldState` or `TextFieldBuffer`. + +--- + +## Task 4 — Extract reusable static built-in block visuals + + +**Dependencies:** Task 1 +**Can partially parallelize with:** Tasks 2 and 3 + +### Context + +Built-in renderer visuals are currently intertwined with editor-only behavior. Extract the smallest stable visual primitives so preview and editor do not drift, without forcing both paths through a complex render-mode switch. + +### Scope + +- Modify `ui/renderers/TextBlockRenderer.kt`. +- Modify `ui/renderers/TodoBlockRenderer.kt`. +- Potentially modify `DividerBlockRenderer.kt`. +- Reuse `blockIndentationInset`, ordered-list prefix formatting, theme, and dimensions. +- Add pure geometry/style tests where new helper functions are introduced. + +### Requirements + +Extract or expose internal primitives for: + +- text style resolution for paragraphs, headings H1–H6, quote, code, and list text; +- quote border/background chrome; +- code block background/padding chrome; +- bullet and numbered-list prefix layout; +- static indentation inset; +- todo checkbox/checkmark drawing; +- divider visual. + +The editor must retain its current: + +- text field; +- focus behavior; +- input behavior; +- animated type transitions; +- interactive todo toggle; +- selection and drag visuals. + +The preview must be able to use: + +- static target text style; +- static indentation without `animateDpAsState`; +- static todo state without `toggleable`; +- no focus or selection arguments. + +### Restrictions and considerations + +- Prefer small content-slot primitives over a large `RenderMode` enum. +- Do not make internal implementation helpers public unless custom preview renderers genuinely need them. +- Do not change padding, list-gutter widths, quote geometry, or code geometry during extraction. +- Avoid creating animation state in preview compositions. +- Maintain exact editor semantics and screenshots before optimizing preview density. + +### Tests + +- Existing renderer tests remain green. +- Pure text-style resolver returns the correct typography slot for every built-in type. +- Static indentation matches the editor’s target indentation at every supported depth. +- Ordered-list prefixes match decimal, alphabetic, and roman ancestry behavior. +- Todo indicator renders checked/unchecked state without exposing a click action in preview mode. +- Code block uses monospace typography and ignores spans. + +### Definition of done + +- [ ] Editor visuals and interaction behavior do not regress. +- [ ] Preview can compose built-in chrome without calling `TextBlockField`. +- [ ] No preview primitive creates focus, pointer input, or animation state. +- [ ] Shared helpers have focused KDoc explaining their editor/preview parity role. + +--- + +## Task 5 — Implement built-in preview renderers + + +**Dependencies:** Tasks 2, 3, and 4 + +### Context + +With the registry channel, static rich-text adapter, and shared chrome available, implement dedicated preview renderers for all built-in block types. + +### Scope + +- Create `PreviewTextBlockRenderer.kt`. +- Create `PreviewTodoBlockRenderer.kt`. +- Create `PreviewDividerBlockRenderer.kt`. +- Modify `ui/EditorRegistry.kt` to register built-in preview renderers. +- Add focused common/desktop tests. + +### Requirements + +- Paragraph: themed body text. +- Heading: correct H1–H6 typography. +- Bullet list: prefix gutter and indentation matching the editor. +- Numbered list: derived prefix style and stored number. +- Todo: static checked/unchecked control plus body text; checked text retains base strikethrough. +- Quote: quote chrome plus italic body style. +- Code: monospace, preserved line breaks, code background, no span styling. +- Divider: same visible line geometry as the editor. +- All text blocks: + - use `BasicText`; + - apply `AnnotatedString` span mapping when supported; + - obey max-line and overflow config; + - never gain focus; + - never expose editable semantics. + +### Restrictions and considerations + +- Do not invoke `TextBlockRenderer` or `TextBlockField`. +- Do not use `BasicTextField(readOnly = true)`. +- Do not create disabled interactive controls merely to obtain visuals. +- Use stable keys/remember inputs so grid-item recomposition does not rebuild annotated strings unnecessarily. +- Ensure checked-todo base strikethrough composes correctly with explicit underline, link, and inline-code spans. +- Multiline code overflow needs explicit tests because ellipsis behavior can differ from proportional body text. + +### Tests + +- Compose a document containing every built-in block type and assert visible text/semantics. +- Assert heading/list/quote/code/todo nodes are present with expected structural semantics where observable. +- Assert no node exposes editable-text semantics. +- Assert tapping plain text does not request focus or open a keyboard. +- Assert checked todo exposes checked state but no toggle action. +- Assert `maxLinesPerTextBlock` and overflow apply to each text block. +- Assert code block does not apply stored spans. +- Assert built-in preview renderers are present in `createEditorRegistry()`. + +### Definition of done + +- [ ] Every built-in block type has a preview renderer. +- [ ] No built-in preview uses editor runtime state. +- [ ] Editor and preview share visual primitives rather than copied constants. +- [ ] Desktop UI tests prove static/non-editable semantics. + +--- + +## Task 6 — Implement the CascadeDocumentPreview container + + +**Dependencies:** Tasks 1, 2, and 5 + +### Context + +The container supplies theme/context, applies the bounded block prefix, resolves renderers, catches block failures, and lays blocks out without nested scrolling. + +### Scope + +- Create `ui/CascadeDocumentPreview.kt`. +- Wire `DefaultBlockPreviewScope`. +- Reuse ordered-list visual context and crash containment. +- Create or extend `CascadeDocumentPreviewTest.kt`. + +### Requirements + +- Accept an ordered `List`. +- Select no more than the configured block limit. +- Compute ordered-list prefix context from the original prefix needed for correct first-part rendering. +- Use a non-scrollable `Column` or purpose-built non-scrollable layout. +- Apply stable composition keys for blocks. +- Render blocks in source order. +- Resolve preview renderers through `BlockRegistry`. +- Provide existing Cascade theme and localization CompositionLocals needed by built-in/custom preview renderers. +- Use per-block crash containment. +- Clip to the caller-provided bounds where required, without inventing a card size. +- Render empty input as an empty layout; do not append a paragraph. +- Avoid a built-in bottom padding intended for the editor scroll viewport. + +### Restrictions and considerations + +- No `LazyColumn`, `verticalScroll`, nested scroll, or `ScrollState`. +- No `EditorState` construction solely for rendering. +- No `ensureTrailingTextBlock()`. +- No document normalization or renumbering. +- Stable key strategy must not crash on malformed duplicate block IDs. Prefer a deterministic composite key including index rather than trusting uniqueness at the never-crash boundary. +- Keep block lookup in `BlockPreviewScope` efficient. Build an ID map once per input list if custom renderers need repeated lookup. +- A large unbounded list will compose eagerly; KDoc and presets must make this explicit. + +### Tests + +- Empty blocks render without children and without failure. +- Input ordering is preserved. +- Block limit renders exactly the configured prefix. +- Hidden blocks are never composed. +- No trailing paragraph is added. +- Duplicate IDs do not crash composition. +- Registry revision causes an already-visible fallback to switch to a newly registered preview renderer. +- Root semantics expose no vertical scroll action. +- A fixed-height preview nested in a `LazyVerticalGrid` can be measured without an infinite-constraint exception. +- A failing renderer degrades only its own block and later blocks still render. + +### Definition of done + +- [ ] Public preview composable works with built-in renderers. +- [ ] Container has no editor runtime or scroll state. +- [ ] Empty, malformed, and duplicate-ID inputs remain contained. +- [ ] Nested-grid Compose test passes. +- [ ] Per-block failure isolation works. + +--- + +## Task 7 — Add link opening, optional selection, and accessibility semantics + + +**Dependencies:** Tasks 3, 5, and 6 + +### Context + +Preview cards may need links and copy, but those interactions must not quietly reintroduce text-field infrastructure or conflict with whole-card navigation. + +### Scope + +- Extend `PreviewAnnotatedString.kt` with link annotations. +- Wire link opening through `BlockPreviewScope`. +- Add optional `SelectionContainer` at the narrowest safe boundary. +- Add accessibility tests and platform QA checklist. + +### Requirements + +- Existing stored link targets render using the preview link color and underline. +- Links invoke `onOpenLink` only when: + - links are enabled; + - the target is present/valid under the existing stored-target policy; + - the user activates the link rather than the surrounding card. +- Link activation must not focus text or open the IME. +- With selection disabled: + - text is static; + - the preview installs no selection handles; + - outer card click behavior remains available to the host. +- With selection enabled: + - text can be selected/copied using Compose selection primitives; + - no sentinel can enter copied text; + - selection never mutates the document. +- Todos expose their checked state accessibly without a toggle action. +- Unknown/fallback blocks expose an understandable localized label. + +### Restrictions and considerations + +- Do not reuse editor `LinkPopupController`, `LinkState`, `LinkActions`, or `LinkHitTester`. +- Prefer Compose link annotations over custom pointer hit-testing when cross-platform behavior is sufficient. +- Explicitly test event arbitration when the host wraps the whole preview in `clickable`. +- Selection across multiple block descendants may vary by platform; do not promise it until QA proves it. +- Visually ellipsized text may still expose full source text through semantics. Document this behavior or project text content before rendering if the product requires semantic truncation. + +### Tests + +- Valid link activates exactly once. +- Links disabled means no activation. +- Missing opener means safe no-op. +- Plain text tap does not invoke link callback. +- Overlapping link and bold/italic styles retain both visuals. +- Outer card click still fires when non-link text is tapped in default mode. +- Link activation does not also fire outer card navigation if event semantics can prevent it. +- Selection-disabled preview exposes no selection action. +- Selection-enabled preview returns exactly visible text without a sentinel. +- Checked/unchecked todo semantics are correct and non-actionable. + +### Manual platform QA + +- [ ] Android touch link activation and long-press selection. +- [ ] iOS link activation and selection handles. +- [ ] Desktop mouse link activation and drag selection. +- [ ] wasm browser link activation, selection, and clipboard. +- [ ] Screen reader pass on Android and iOS. + +### Definition of done + +- [ ] Link opening works without editor link infrastructure. +- [ ] Default preview remains non-focusable and card-friendly. +- [ ] Selection behavior is explicit and tested. +- [ ] Accessibility contract is documented. + +--- + +## Task 8 — Harden unknown and custom preview behavior + + +**Dependencies:** Tasks 2 and 6 + +### Context + +Custom blocks are a first-class Cascade capability. Preview mode must degrade predictably when no lightweight renderer is registered and must contain custom renderer failures. + +### Scope + +- Create `UnknownBlockPreviewRenderer.kt`. +- Register it as the preview fallback. +- Add custom renderer integration tests. +- Reuse `CrashPolicy`, `CascadeError`, and error reporter contracts. + +### Requirements + +- If an unknown/custom block has text content, fallback preserves a bounded readable excerpt where safe. +- If content is non-text/custom payload, fallback shows a localized unsupported-block label. +- Fallback must never invoke the editor renderer. +- Custom preview renderers receive only `BlockPreviewScope`. +- A custom renderer failure during composition, measure, or draw is contained according to preview config. +- The error context identifies preview rendering and block type. +- A faulty error reporter cannot turn a contained preview error into a host crash. +- Platform-view guidance states: + - preview renderer should use a thumbnail/placeholder where possible; + - it must not mount a second heavy editor/controller; + - consumers own media lifecycle and caching. + +### Restrictions and considerations + +- Do not deserialize or interpret custom payloads in the generic fallback. +- Do not call `ScopedBlockRenderer`. +- Do not claim full visual fidelity for custom blocks without a registered preview renderer. +- Keep fallback height bounded so one unsupported block cannot dominate a grid card. + +### Tests + +- Unknown text block shows a text-preserving fallback. +- Unknown custom-data block shows localized label. +- Existing editor renderer without preview renderer still uses fallback. +- Registered custom preview renderer replaces fallback. +- Throwing custom renderer reports once and falls back. +- Throwing error reporter is contained. +- One failing custom block does not suppress following blocks. + +### Definition of done + +- [ ] Custom-block behavior is deterministic and documented. +- [ ] No editor renderer is invoked by fallback. +- [ ] Failure containment covers composition/measure/draw. +- [ ] Preview extension example compiles against public API only. + +--- + +## Task 9 — Complete API compatibility and cross-cutting regression coverage + + +**Dependencies:** Tasks 1–8 + +### Context + +The feature adds public API to a published Kotlin Multiplatform library and refactors shared visual code. It needs explicit compatibility and regression gates beyond feature-local tests. + +### Scope + +- Add/complete `CascadeDocumentPreviewTest.kt`. +- Add editor-versus-preview parity fixtures. +- Run and update binary compatibility snapshots. +- Add source-compatibility sample for existing renderer consumers. +- Update `ARCHITECTURE.md` test matrix. + +### Requirements + +- Verify editor behavior is unchanged for: + - editable text; + - read-only selection/copy; + - todo toggling; + - headings and type animation; + - list prefixes and indentation; + - quote/code styling; + - custom editor renderers; + - scoped custom renderers. +- Verify preview behavior has no editor runtime semantics: + - no editable text; + - no focus action; + - no scroll action; + - no selection action by default; + - no mutation callbacks. +- Verify public API snapshots for JVM/Android and klib targets. +- Verify new API uses explicit visibility and KDoc. + +### Tests + +- Existing full `desktopTest` and `allTests`. +- API compatibility validation. +- Editor renderer integration test proving `ScopedBlockRenderer` still receives live scope only inside `CascadeEditor`. +- Preview integration test proving custom preview renderer receives read-only preview scope. +- Parity tests for every built-in block type and span style. +- Malformed input test matrix: empty, duplicate IDs, bad indentation, invalid spans, unknown types. + +### Definition of done + +- [ ] `./gradlew :editor:desktopTest` passes. +- [ ] `./gradlew :editor:allTests` passes. +- [ ] Intentional API snapshots are refreshed with `./gradlew :editor:apiDump`. +- [ ] `./gradlew :editor:apiCheck` passes after snapshot review. +- [ ] Existing public editor renderer source samples compile unchanged. +- [ ] `ARCHITECTURE.md` contains all new files, symbols, contracts, and tests. + +--- + +## Task 10 — Add the note-grid sample and consumer guidance + + +**Dependencies:** Tasks 6–9 + +### Context + +The sample must demonstrate the intended integration and make accidental per-card allocation patterns visible during review. + +### Scope + +- Add a sample Preview Gallery screen. +- Add navigation entry. +- Add representative note-card fixtures. +- Add a consumer-side preview state/projection example. + +### Requirements + +- Render at least 50 notes in a `LazyVerticalGrid`. +- Show 6–10 cards on a representative large screen. +- Hoist and share: + - one stable `BlockRegistry`; + - one stable theme; + - one stable localization set. +- Keep preview documents cached outside card composition. +- Give every grid item a stable note ID key. +- Give cards an explicit size/aspect ratio. +- Demonstrate: + - simple note; + - span-heavy note; + - nested list; + - todo note; + - quote/code note; + - unknown block fallback; + - custom block preview; + - link activation; + - card navigation wrapper. +- Optionally provide a toggle between `CascadeDocumentPreview` and full read-only `CascadeEditor` for manual comparison, but do not present the full-editor path as recommended. + +### Restrictions and considerations + +- Do not parse JSON/HTML/Markdown inside an item composable. +- Do not instantiate a registry inside each card. +- Do not store one editor state holder per preview. +- Do not add internal scrolling to the card. +- Avoid remote media in the benchmark fixture. + +### Tests + +- Screen model/projection test verifies stable bounded preview data. +- Desktop UI smoke test confirms grid cards render and scroll. +- Link versus card-click arbitration test. +- Unknown and custom preview cards render without failure. + +### Definition of done + +- [ ] Sample demonstrates the recommended architecture. +- [ ] No per-card editor runtime objects are created. +- [ ] Grid scroll is owned only by the outer lazy grid. +- [ ] Sample is suitable for README screenshots and performance benchmarking. + +--- + +## Task 11 — Add Android performance benchmarks and release thresholds + + +**Dependencies:** Task 10 +**Can be developed in parallel with:** Documentation after sample contract is stable + +### Context + +The feature exists to reduce grid rendering cost. Architectural inspection is necessary but insufficient; release-mode frame timing and allocation evidence should prove the result. + +### Scope + +- Add or extend an Android Macrobenchmark module. +- Add a benchmark-only route into the sample. +- Measure opening and flinging the preview grid. +- Compare preview cards with read-only editor cards using the same visible content. +- Capture trace and memory evidence. + +### Benchmark scenarios + +1. **Simple grid** + - 100 notes; + - 6–10 visible cards; + - heading + two paragraphs per note. + +2. **Span-heavy grid** + - overlapping bold/italic/underline/strike/link/inline-code/highlight spans; + - long first paragraph constrained by preview limits. + +3. **Structural grid** + - nested lists, todos, quote, multiline code, divider. + +4. **Custom-block grid** + - lightweight custom preview renderer; + - unknown-block fallback; + - deliberately failing renderer in a non-performance containment test. + +5. **Comparison grid** + - equivalent content rendered through `CascadeEditor(readOnly = true)` with toolbar hidden; + - fixed card size; + - used only as a baseline comparison. + +### Metrics + +- `FrameTimingMetric.frameOverrunMs` at p50/p90/p95/p99. +- `FrameTimingMetric.frameDurationCpuMs`. +- Time to first fully rendered grid. +- Allocations and GC activity during a fixed fling. +- Memory retained after returning from the grid. +- Compose recomposition counts for representative cards. +- Trace inspection for unexpected `BasicTextField`, IME, history, or lazy-list work. + +### Acceptance gates + +- Preview root exposes no nested vertical scroll. +- No `TextFieldState`, text history tracker, editor checkpoint, or per-card `LazyListState` is created by preview code. +- Release/R8 grid scrolling meets the target frame budget on the agreed reference mid-range Android device. +- p95/p99 overrun is materially better than the full read-only editor comparison. +- Allocation/GC behavior shows no repeated full-document checkpoint work as cards enter composition. +- Results are captured in a short checked-in performance note; do not publish exact claims until measurements are reproducible. + +### Restrictions and considerations + +- Measure release/R8 builds, never use debug performance as evidence. +- Warm up and use multiple iterations. +- Keep device, refresh rate, OS version, compilation mode, and fixture version in results. +- Avoid network, remote images, and random content. +- Do not choose only a flagship device. + +### Definition of done + +- [ ] Macrobenchmark is repeatable from one documented Gradle command. +- [ ] Trace artifacts can be opened in Perfetto/Android Studio. +- [ ] Reference results are recorded with environment metadata. +- [ ] Any missed threshold has a named follow-up issue; no silent waiver. + +--- + +## Task 12 — Documentation, architecture, and release preparation + + +**Dependencies:** Tasks 1–11 + +### Scope + +- Complete `docs/DocumentPreview.md`. +- Update `README.md`. +- Update `ARCHITECTURE.md`. +- Update API snapshots. +- Add release notes/changelog entry when a release version is selected. + +### Documentation requirements + +`docs/DocumentPreview.md` must cover: + +- preview versus read-only editor; +- performance model; +- public API; +- recommended grid-card configuration; +- registry hoisting; +- document projection and caching outside composition; +- built-in block behavior; +- link and selection behavior; +- custom preview renderer tutorial; +- unknown/custom fallback; +- accessibility contract; +- crash containment; +- limitations and platform caveats; +- benchmark methodology and measured results. + +`README.md` should include: + +- one concise “Preview documents in lists and grids” example; +- a statement that preview uses static Compose text and no editor runtime; +- a link to full docs; +- no unverified performance multiplier. + +`ARCHITECTURE.md` must include: + +- all new public/internal files in Quick Reference; +- preview renderer contract and registry channel; +- static rich-text adapter; +- container data flow; +- implementation status; +- test matrix entries; +- convention that editor renderers are never automatic preview fallbacks. + +### Definition of done + +- [ ] Public API KDoc is complete. +- [ ] README example uses a shared registry and stable grid keys. +- [ ] Documentation does not confuse preview with permission/read-only mode. +- [ ] Architecture and test matrix are current. +- [ ] API snapshot diff contains only intentional additions. +- [ ] Release note states custom-block fallback behavior. + +--- + +## Optional Task 13 — Add a native Swift/UIKit preview facade + + +**Dependencies:** Stable completion of Tasks 1–12 +**Recommended status:** Deferred unless required for the first release + +### Scope + +- Add a native controller/view facade in `editor-ios-sdk`. +- Expose document loading and preview configuration to Swift. +- Add a native custom-preview renderer adapter or documented fallback. +- Add lifecycle and memory tests. +- Update `docs/iOsNativeSdk.md`. + +### Requirements + +- Swift can create a preview from the same document JSON or controller-owned blocks. +- Preview view does not instantiate the editable controller runtime. +- Native custom editor blocks do not automatically mount in preview mode. +- Custom preview providers are explicitly registered. +- UIKit sizing is intrinsic/bounded and never installs an internal vertical scroll view. +- Reuse one Compose host only as appropriate for the owning preview view; do not create hidden editor controllers. + +### Definition of done + +- [ ] Swift sample renders a grid/list of previews. +- [ ] Controller lifecycle releases preview state when cells are reused. +- [ ] Native custom fallback is documented. +- [ ] iOS memory and scrolling QA passes. + +--- + +## Dependency and Implementation Order + +1. **Task 1 — Contract/config/fixtures** + Foundation for every subsequent task. + +2. **Task 2 — Preview renderer registry** + Depends on Task 1. Can run in parallel with Task 3. + +3. **Task 3 — Static rich-text adapter** + Depends on Task 1. Can run in parallel with Task 2. + +4. **Task 4 — Shared built-in visual primitives** + Depends on Task 1. Can begin while Tasks 2 and 3 are underway. + +5. **Task 5 — Built-in preview renderers** + Depends on Tasks 2, 3, and 4. + +6. **Task 6 — Preview container** + Depends on Tasks 1, 2, and 5. + +7. **Task 7 — Links/selection/accessibility** + Depends on Tasks 3, 5, and 6. + +8. **Task 8 — Unknown/custom hardening** + Depends on Tasks 2 and 6. Can overlap with Task 7. + +9. **Task 9 — Compatibility/regression gate** + Depends on Tasks 1–8. + +10. **Task 10 — Sample grid** + Depends on Tasks 6–9. + +11. **Task 11 — Android performance proof** + Depends on Task 10. Documentation drafting can proceed in parallel. + +12. **Task 12 — Final docs/release prep** + Depends on the stable behavior and measured results from prior tasks. + +13. **Optional Task 13 — Native Swift/UIKit facade** + Starts only after the Compose API is stable. + +--- + +## Test Matrix + +| Area | Common unit | Desktop Compose | Android benchmark/manual | iOS manual | wasm manual | +|---|:---:|:---:|:---:|:---:|:---:| +| Config validation | ✓ | | | | | +| Registry resolution/revision | ✓ | ✓ | | | | +| Span mapping/parity | ✓ | ✓ | | | | +| Built-in block rendering | | ✓ | ✓ | ✓ | ✓ | +| Block/line limits | ✓ | ✓ | ✓ | ✓ | ✓ | +| No editable/focus semantics | | ✓ | ✓ | ✓ | ✓ | +| No internal scroll | | ✓ | ✓ | ✓ | ✓ | +| Link activation | ✓ | ✓ | ✓ | ✓ | ✓ | +| Optional selection/copy | | ✓ | ✓ | ✓ | ✓ | +| Unknown/custom fallback | ✓ | ✓ | ✓ | ✓ | ✓ | +| Crash containment | ✓ | ✓ | ✓ | ✓ | | +| Grid frame timing/allocation | | | ✓ | Recommended | | +| API compatibility | ✓ | | ✓ | ✓ klib | ✓ klib | + +--- + +## Performance Invariants + +The following are architectural invariants, not optional optimizations: + +- One preview instance creates zero `EditorStateHolder` objects. +- One preview instance creates zero `TextFieldState` objects. +- One preview text block creates zero history trackers and zero document checkpoints. +- One preview instance creates zero `LazyListState` objects. +- One preview instance launches no perpetual text/selection observation coroutine. +- Preview entry into composition performs work proportional to the rendered block prefix and its rendered text/spans, not to hidden editor runtime systems. +- A custom block with no preview renderer uses a bounded fallback. +- Animated state is not created for static preview typography, indentation, or todo state. +- Outer grid/list owns all vertical scrolling. +- JSON/HTML/Markdown parsing does not occur in the preview composable. + +--- + +## Key Risks and Mitigations + +### Visual drift between editor and preview + +**Risk:** Dedicated preview renderers slowly diverge from editor colors, spacing, list markers, and span rules. +**Mitigation:** Extract pure style/chrome primitives, maintain parity fixtures, and add paired editor/preview tests for every built-in block. + +### Public API overreach + +**Risk:** Preview scope grows editor-like mutation or application navigation capabilities. +**Mitigation:** Keep `BlockPreviewScope` read-only and allow only link opening as a built-in interaction. + +### Unsafe reuse of custom editor renderers + +**Risk:** A custom editor renderer mounts heavy platform views, reads missing locals, or mutates state. +**Mitigation:** Separate preview renderer registry; never automatic-fallback to editor renderer. + +### Large unbounded input + +**Risk:** A consumer uses `Unbounded` inside a grid and composes an entire note per cell. +**Mitigation:** Bounded default/preset, strong KDoc, sample best practice, benchmark fixture, and optional debug warning if an unbounded preview is used with a large list. + +### Link versus card navigation + +**Risk:** Link activation also triggers the surrounding card’s click. +**Mitigation:** Dedicated event-arbitration tests and documented modifier ordering. + +### Selection differences across platforms + +**Risk:** `SelectionContainer` behavior or clipboard semantics differ on iOS/wasm. +**Mitigation:** Default off, explicit opt-in, cross-platform manual gate before promising support. + +### Malformed documents + +**Risk:** Duplicate IDs, invalid spans, unknown blocks, or invalid indentation crash composition. +**Mitigation:** Composite keys, range clamping, bounded fallback, per-block crash boundary, no normalization assumptions. + +### Premature performance claims + +**Risk:** README claims a multiplier based on local/debug observation. +**Mitigation:** Publish only repeatable release benchmark data with device and fixture metadata. + +--- + +## Rollout Strategy + +### Phase 1 — Internal vertical slice + +- Built-in static rendering. +- Bounded container. +- No links, selection, or custom preview renderer. +- Preview Gallery sample behind a development navigation entry. +- Confirm visual direction and absence of nested scroll. + +### Phase 2 — Public experimental API + +- Public config and composable. +- Link opening. +- Unknown/custom fallback. +- Custom preview renderer contract. +- Accessibility contract. +- Full tests and API snapshots. + +### Phase 3 — Performance proof and public documentation + +- Macrobenchmark. +- Trace/allocation review. +- README/docs/sample polish. +- Experimental release and feedback collection. + +### Phase 4 — Stabilization + +- Incorporate consumer feedback. +- Decide default limits. +- Stabilize API after at least one minor release. +- Consider native Swift/UIKit facade. + +--- + +## Final Release Checklist + +### Architecture + +- [ ] Preview remains stateless and non-scrollable. +- [ ] No editor runtime types appear in the preview implementation path. +- [ ] Editor renderer and preview renderer contracts remain separate. +- [ ] Shared visual mapping has one canonical source. +- [ ] `ARCHITECTURE.md` is current. + +### Functionality + +- [ ] Every built-in block renders. +- [ ] Every built-in span style renders with editor parity. +- [ ] Block and line limits work. +- [ ] Links work according to config. +- [ ] Selection behavior matches documented support. +- [ ] Unknown/custom fallback is bounded and localized. +- [ ] Custom preview renderer can be registered. + +### Safety + +- [ ] Invalid spans are clamped. +- [ ] Duplicate IDs do not crash. +- [ ] Throwing custom renderer is contained. +- [ ] Throwing error reporter is contained. +- [ ] One failed block does not suppress later blocks. + +### Performance + +- [ ] No `TextFieldState`, history checkpoint, or inner lazy list in preview traces. +- [ ] Grid benchmark runs against a release/R8 build. +- [ ] Mid-range device frame metrics meet the agreed budget. +- [ ] Allocation and GC results materially improve over read-only editor cards. +- [ ] Benchmark environment and fixture are recorded. + +### Compatibility + +- [ ] Existing editor tests pass. +- [ ] Existing custom `BlockRenderer` code compiles unchanged. +- [ ] Existing `ScopedBlockRenderer` behavior is unchanged. +- [ ] JVM/Android and klib API snapshots pass validation. +- [ ] Android, iOS, desktop, and wasm smoke checks pass. + +### Documentation + +- [ ] README includes a concise grid-preview example. +- [ ] Full preview documentation exists. +- [ ] Preview versus read-only mode is explicit. +- [ ] Registry hoisting and off-composition parsing are documented. +- [ ] Custom block fallback and extension path are documented. +- [ ] No unverified performance claim is published. + +--- + +## Recommended First Implementation Cut + +The first implementation should be considered successful when a 6–10-card grid: + +- renders the same representative document excerpts as the editor; +- scrolls only at the grid level; +- contains no editable/focus semantics; +- creates no editor runtime state; +- survives malformed and unknown blocks; +- shows materially better release-mode frame and allocation behavior than the full-editor comparison. diff --git a/README.md b/README.md index 59fb18f..a78e42d 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,64 @@ This is a UI boundary inside `CascadeEditor`, not an application authorization l To keep editing enabled but suppress block-level affordances, use `CascadeEditorConfig(blockSelectionEnabled = false)` and/or `CascadeEditorConfig(blockDraggingEnabled = false)`. `readOnly = true` still overrides both flags and disables all editor-owned mutations. +## Preview documents in lists and grids (experimental) + +Use `CascadeDocumentPreview` for bounded card content. It renders immutable +blocks with static Compose text and no `EditorStateHolder`, text field, editor +focus/IME, history, or internal scroll container. This is distinct from +`CascadeEditor(readOnly = true)`, which retains the full reader/editor runtime. + +Hoist one registry outside the lazy-item lambda: + +```kotlin +@OptIn(ExperimentalCascadePreviewApi::class) +@Composable +fun NoteGrid( + notes: List, + onOpenLink: (String) -> Unit, +) { + val previewRegistry = remember { createEditorRegistry() } + val previewTheme = remember { CascadeEditorTheme.light() } + val previewStrings = remember { CascadeEditorStrings.default() } + val previewBlockStrings = remember { CascadeEditorBlockStrings.default() } + + LazyVerticalGrid(columns = GridCells.Adaptive(240.dp)) { + items(notes, key = { it.id }) { note -> + CascadeDocumentPreview( + blocks = note.blocks, + registry = previewRegistry, + theme = previewTheme, + strings = previewStrings, + blockStrings = previewBlockStrings, + config = CascadeDocumentPreviewConfig.GridCard.copy( + textScale = 0.8f, + ), + onOpenLink = onOpenLink, + ) + } + } +} +``` + +`GridCard` renders at most four blocks and three lines per text block. +Its default `textScale` is `1f`; `0.8f` is a useful starting point for +two-column cards. It does not scale `dp` spacing or explicit host bounds, but +text remeasures normally, so wrapping and intrinsic height can change. +`Unbounded` removes both limits but still composes eagerly and does not scroll. +Decode or project documents before item composition, and register a dedicated +`BlockPreviewRenderer` for custom blocks; editor renderers are never used as an +automatic preview fallback. + +Built-in link spans keep Cascade's link styling and use Compose link +annotations when `linksEnabled` is true. Activating a valid stored link invokes +only the host-supplied `onOpenLink` callback; preview never uses an implicit +platform URI opener. Setting `linksEnabled = false` keeps the visual styling +without link click semantics. Omitting `onOpenLink` likewise keeps links +visual-only so the surrounding card can own the click. +Optional Compose text selection is disabled by default. See +[Document Preview](docs/DocumentPreview.md) for configuration, custom renderers, +fallback, accessibility, and crash-containment boundaries. + ## Toolbar For simple inputs, the toolbar is usually the main integration surface: choose which formatting actions are allowed, hide block-level controls, or replace the toolbar with your own app UI. @@ -568,9 +626,9 @@ Cascade Editor uses a shared Kotlin editor core with strict layers for document ``` ┌─────────────────────────────────────────────────────────┐ -│ UI Layer (CascadeEditor, renderers, drag overlays) │ +│ UI Layer (CascadeEditor + preview, renderers) │ ├─────────────────────────────────────────────────────────┤ -│ Text State Layer (BlockTextStates, TextFieldState) │ +│ Text State Layer (editor only: BlockTextStates) │ ├─────────────────────────────────────────────────────────┤ │ State Layer (EditorState, EditorStateHolder) │ ├─────────────────────────────────────────────────────────┤ diff --git a/docs/DocumentPreview.md b/docs/DocumentPreview.md new file mode 100644 index 0000000..a9afe71 --- /dev/null +++ b/docs/DocumentPreview.md @@ -0,0 +1,399 @@ +# Document Preview + +`CascadeDocumentPreview` is an experimental, stateless renderer for bounded +document summaries in cards, feeds, search results, and grids. It renders an +immutable `List` with static Compose content and no editor state holder, +text fields, focus, history, toolbar, drag system, or internal scrolling. + +Opt in while the API is experimental: + +```kotlin +@OptIn(ExperimentalCascadePreviewApi::class) +@Composable +fun NotePreview(blocks: List, registry: BlockRegistry) { + CascadeDocumentPreview( + blocks = blocks, + registry = registry, + config = CascadeDocumentPreviewConfig.GridCard, + ) +} +``` + +## Preview or read-only editor? + +The two surfaces solve different problems. + +| | `CascadeDocumentPreview` | `CascadeEditor(readOnly = true)` | +|---|---|---| +| Input | Immutable `List` | `EditorStateHolder` and editor runtime state | +| Text | Static `BasicText` | Read-only editor text fields | +| Scrolling | None; the host owns scrolling | Editor-owned document scrolling | +| Selection | Off by default; optional Compose selection | Reader-oriented selection/copy | +| Focus and IME | No editor text focus or IME; links keep normal accessibility focus | Editor focus infrastructure remains available | +| Transition to editing | Mount the editor separately | Can change editor configuration in place | +| Intended use | Bounded summaries and card content | Full read-only document viewing | + +Neither surface is an authorization boundary. Enforce document permissions in +application state, persistence, and synchronization code. + +## Lists and grids + +Create one registry outside the item lambda and reuse it for every preview: + +```kotlin +@OptIn(ExperimentalCascadePreviewApi::class) +@Composable +fun NoteGrid( + notes: List, + onOpenLink: (String) -> Unit, +) { + val previewRegistry = remember { createEditorRegistry() } + val previewTheme = remember { CascadeEditorTheme.light() } + val previewStrings = remember { CascadeEditorStrings.default() } + val previewBlockStrings = remember { CascadeEditorBlockStrings.default() } + + LazyVerticalGrid(columns = GridCells.Adaptive(240.dp)) { + items(notes, key = { it.id }) { note -> + CascadeDocumentPreview( + blocks = note.blocks, + registry = previewRegistry, + theme = previewTheme, + strings = previewStrings, + blockStrings = previewBlockStrings, + config = CascadeDocumentPreviewConfig.GridCard.copy( + textScale = 0.8f, + ), + modifier = Modifier.fillMaxWidth(), + onOpenLink = onOpenLink, + ) + } + } +} +``` + +The preview uses a non-scrollable `Column`; the surrounding list or grid owns +vertical scrolling and card sizing. Hoist the registry, theme, and localization +objects once for a large collection, as above. + +Configure a registry on the UI thread before sharing it. A single +`BlockRegistry` may hold both editor and preview renderers, but they remain +separate channels. + +## Swift and UIKit + +The Native iOS SDK exposes the same bounded preview through +`CascadeDocumentPreviewController`. This native facade is also experimental; +its source and binary API may change before preview mode is stabilized. It +accepts document JSON through `loadJson`, owns only decoded immutable blocks +plus preview presentation state, and returns a transparent, non-scrolling +`UIViewController` from `makeViewController()`. It does not create a +`CascadeEditorController`, editor state holder, runtime text/span state, +history, focus, slash commands, drag behavior, or editor mutation callbacks. + +`CascadeDocumentPreviewConfiguration` provides Swift-friendly bounded policy: +`maxBlocks`, `maxLinesPerTextBlock`, `textScale`, `textSelectionEnabled`, +`linksEnabled`, `isDark`, and `crashPolicy`. The defaults match the grid-card +policy: four blocks, three lines, `1.0` text scale, selection disabled, links +enabled, light mode, and `containAndReport`. Native limits are non-null; values +below `1` are normalized to `1` before reaching the core preview. Invalid text +scales (non-finite, non-positive, or non-positive/non-finite after conversion +to the core `Float`) normalize to `1.0`. + +Create and retain one controller and one returned view controller for each live +UIKit or SwiftUI cell. Supply a bounded frame, update the existing controller +with `loadJson(...)`, `updateConfiguration(...)`, or `setDarkMode(...)`, and +release it when the cell leaves the collection. The outer collection owns +scrolling, stable item identity, reuse, and navigation. + +```swift +let controller = CascadeDocumentPreviewController( + configuration: CascadeDocumentPreviewConfiguration( + maxBlocks: 4, maxLinesPerTextBlock: 3, + textScale: 0.8, + textSelectionEnabled: false, linksEnabled: false, + isDark: isDark, crashPolicy: .containAndReport + ) +) +let loadResult = controller.loadJson(json: document.json) +guard loadResult.success else { + // Surface or recover the malformed stored document. + return +} +let previewViewController = controller.makeViewController() +``` + +A successful `loadJson(json:)` replaces the immutable preview snapshot and +returns any non-fatal decode warnings. A parse failure returns `success = false` +and preserves the preview already on screen. `onInternalError` receives +contained preview errors and main-thread misuse. `onOpenLink` is the only link +opening seam; without that callback, links are visual-only. + +The native sample is an integration reference rather than a second editor +implementation: + +- `CascadeDocumentPreviewHost` embeds the controller with + `UIViewControllerRepresentable`; +- `PreviewGalleryScreen` supplies the `LazyVGrid`, bounded card frames, stable + models, and card-level navigation; +- `PreviewDocumentLibrary` copies bundled JSON fixtures into app-owned storage; +- selecting a card mounts a separate `CascadeEditorController` in + `PreviewDocumentEditorScreen`; +- debounced editor exports update the library, pending edits are synchronously + flushed before backgrounding or leaving, and a failed Back-button flush keeps + the editor open; +- each successful save updates the library, then the retained card controller + reloads that JSON and refreshes its static preview. + +The sample disables preview links and hit testing inside each card so the outer +button owns the complete tap target. A host that wants interactive links can +enable them and install `onOpenLink`, but must define how link taps arbitrate +with card navigation. + +Native custom editor registrations are intentionally isolated from preview +mode. The facade preserves unknown block types and opaque custom content during +JSON decode, then uses the bounded generic preview fallback. It never mounts +native UIKit/SwiftUI editor renderers. There is no Swift custom +preview-provider registration API yet. + +## Configuration + +`CascadeDocumentPreviewConfig.Default` is the same instance as `GridCard`: + +- at most 4 blocks; +- at most 3 lines per text block; +- `1f` text scale; +- ellipsis overflow; +- text selection disabled; +- built-in link annotations and scope link delegation enabled; +- `CrashPolicy.ContainAndReport`. + +Both limits accept only positive values. A `null` limit means that dimension is +unbounded. `textScale` must be finite and greater than zero. Prefer the named +preset when removing both bounds: + +```kotlin +CascadeDocumentPreview( + blocks = smallStaticDocument, + config = CascadeDocumentPreviewConfig.Unbounded, +) +``` + +`Unbounded` is still non-scrollable and composes every supplied block eagerly. +It is intended only for known-small static documents, not list or grid cells. +Use `GridCard.copy(...)` to tune one policy while preserving the others: + +```kotlin +val searchResultConfig = CascadeDocumentPreviewConfig.GridCard.copy( + maxBlocks = 2, + maxLinesPerTextBlock = 2, + textScale = 0.8f, + textSelectionEnabled = false, +) +``` + +`textScale: Float = 1f` multiplies the host font scale for every `sp` rendered +inside the preview, including text from custom preview renderers. It does not +scale `dp`-based padding, indentation, spacing, or explicit host-imposed card +bounds. Text remeasures normally, so wrapping and intrinsic preview height can +change. A value near `0.8f` is a practical starting point for two-column grids +while preserving the typographic hierarchy. + +Limits are presentation-only. Preview never truncates the stored model, +renumbers lists, normalizes indentation, or appends the editor's trailing empty +paragraph. + +## Immutable input and projection + +Supply immutable block snapshots. Keep decoding and projection outside preview +composition: + +- decode JSON, HTML, or Markdown in a repository, view model, or other data + boundary; +- cache the resulting `List` in the screen's state model; +- pass stable document and item identities to the lazy container; +- do not parse persisted content inside a grid item lambda. + +If the source is a mounted editor, project live text and spans with +`EditorStateHolder.resolveDocumentBlocks(textStates, spanStates)` at a +controlled synchronization boundary, then publish that result as preview +state. `CascadeDocumentPreview` intentionally does not observe +`BlockTextStates` or `BlockSpanStates`. + +The component retains the immutable input list rather than copying the full +document. It selects only the configured prefix for bounded rendering. Custom +scope lookup builds its block-ID index lazily; duplicate IDs resolve to the +first source-order block. + +## Built-in rendering + +`createEditorRegistry()` includes dedicated preview renderers for every built-in +block type: + +- paragraphs and headings use the matching Cascade typography; +- bullet and numbered lists preserve static indentation and prefix layout; +- quotes share the editor's quote chrome and italic body treatment; +- code blocks preserve line breaks, use monospace/code chrome, and deliberately + ignore rich-text spans; +- todos render a static checked or unchecked indicator; checked text retains + its base strikethrough; +- dividers reuse the editor's visible divider geometry. + +Text blocks use `BasicText` and a static `AnnotatedString`. Supported rich-text +styles share the editor's visual span mapping, and the preview introduces no +editor sentinel character. Built-in preview rendering creates no editable text +semantics, editor text-focus target, pointer-input editor behavior, or +animation state. Enabled link annotations retain their normal accessibility +and keyboard focus semantics without focusing text or opening the IME. + +## Links and selection + +Stored link spans receive the normal link color and underline. When +`linksEnabled` is true and the host supplies `onOpenLink`, built-in preview text +also installs Compose clickable link annotations. Activating a valid link trims +surrounding whitespace, rejects a blank target, and invokes the host callback +exactly once. Preview never delegates to `LocalUriHandler` or another implicit +platform opener. + +When `linksEnabled` is false, built-in text retains link styling but installs no +link click semantics, and `BlockPreviewScope.openLink(target)` becomes a no-op. +Omitting `onOpenLink` also leaves built-in links visual-only. The link text does +not install a dead clickable overlay, so a surrounding card can continue to own +ordinary navigation. + +The same policy-aware `BlockPreviewScope.openLink(target)` seam is available to +custom preview renderers. It applies the stored-target validation above before +delegating to the host. A custom renderer must attach its own deliberate link +interaction and account for any surrounding card click behavior. + +Selection is disabled by default so a card can own normal pointer interaction. +Setting `textSelectionEnabled = true` wraps the preview in a Compose +`SelectionContainer`; it does not introduce a text field, focus, document +mutation, or an editor sentinel. Selection handles, cross-block selection, and +clipboard behavior can vary by platform and should be verified on every +supported target. + +Line limits are visual measurement limits. Ellipsized text may still expose its +full source text through semantics; do not treat visual ellipsis as data +redaction. + +## Custom preview renderers + +Editor renderers are never invoked automatically in preview mode. Register a +separate stateless renderer for each custom type that needs a tailored summary: + +```kotlin +@OptIn(ExperimentalCascadePreviewApi::class) +public object CalloutPreviewRenderer : BlockPreviewRenderer { + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) { + val text = (block.content as? BlockContent.Text)?.text.orEmpty() + BasicText( + text = text, + modifier = modifier, + maxLines = scope.config.maxLinesPerTextBlock ?: Int.MAX_VALUE, + overflow = scope.config.textOverflow, + ) + } +} + +@OptIn(ExperimentalCascadePreviewApi::class) +fun createPreviewRegistry(): BlockRegistry { + return createEditorRegistry().apply { + registerPreviewRenderer(CalloutBlock.typeId, CalloutPreviewRenderer) + } +} +``` + +`BlockPreviewScope` exposes only the original ordered blocks, current preview +configuration, block lookup, `canOpenLinks`, and policy-aware link opening. +Custom renderers should install clickable link semantics only when +`canOpenLinks` is true. The scope has no editor actions, mutation callbacks, +editor focus, history, text/span state, or mutation surface. + +Custom renderer text expressed in `sp` automatically follows +`scope.config.textScale` through the preview-local font scale; do not multiply +font sizes by the value again. `dp` tokens are not scaled directly, though text +remeasurement may affect the surrounding layout. + +For media or platform-view custom blocks, prefer a cached thumbnail or bounded +placeholder. A preview renderer should not mount another editor or heavy +controller; the application owns media loading, lifecycle, and caching. + +## Missing-renderer fallback + +When no preview renderer is registered, the registry's preview fallback is +used. The built-in fallback: + +- preserves nonblank plain-text content as an excerpt of at most three lines; +- shows a localized unsupported-block label for empty or opaque custom data; +- never inspects arbitrary custom payloads; +- never invokes an editor `BlockRenderer` or `ScopedBlockRenderer`. + +Registering only an editor renderer therefore does not change preview behavior. +Use `setUnknownBlockPreviewRenderer(...)` to replace the fallback for a +product-specific registry. + +## Blocks without text content + +`DocumentSchema` decodes a block whose JSON omits `content` as +`BlockContent.Empty`, with no warning and regardless of block type, so a +text block or todo can reach preview rendering without `BlockContent.Text`. + +The built-in text and todo preview renderers present those blocks as an empty +line — a todo keeps its checkbox — rather than rendering nothing. A block that +consumes one of the `maxBlocks` slots always occupies that slot, so a bounded +card cannot silently shrink. Custom preview renderers should apply the same +rule; returning early on a content type mismatch leaves an invisible gap. + +## Accessibility + +Built-in preview text is static and non-editable. Todos expose checkbox role and +checked state without a click or toggle action. Unsupported blocks expose a +localized label, and the preview root exposes no vertical-scroll action. + +Custom renderers own their semantics. Give thumbnails and placeholders useful +labels, expose state without fake disabled controls, and keep interactive +elements explicit. Verify selection, clipboard, and screen-reader behavior on +Android, iOS, desktop, and wasm before promising identical cross-platform +behavior. + +## Crash containment + +The default `CrashPolicy.ContainAndReport` applies a per-block containment +modifier around preview measurement, placement, and drawing. A contained +failure is reported with preview/block context through `onInternalError` at +most once per mounted renderer; later layout/draw passes skip that failed +renderer instead of repeatedly throwing. Reporter failures are also contained. +`CrashPolicy.Rethrow` is useful in tests and debug builds. + +Compose does not support a reliable `try/catch` boundary around a composable +invocation. A custom renderer that throws during composition can still fail the +host composition and remains a trusted-code boundary. The current containment +guarantee is specifically for measurement, placement, and drawing; the +missing-renderer fallback does not turn arbitrary composition exceptions into +safe UI. + +## Current limitations + +- The API is experimental and may change before stabilization. +- Preview is not a full-document reader, scroll container, permission boundary, + or semantic summary generator. +- `Unbounded` composes eagerly and is unsuitable for unknown-size grid content. +- Link navigation requires an explicit `onOpenLink` callback; preview has no + implicit platform URI opener. +- Cross-platform selection and clipboard details require host QA. +- Custom blocks need dedicated preview renderers for product-specific fidelity. +- Remote images, embeds, media lifecycle, and caching are application concerns. +- The Swift/UIKit facade currently has no native custom preview-provider API; + custom editor renderers never mount and unknown/custom blocks use the generic + bounded fallback. + +The architecture removes editor runtime systems from preview composition, but +this document makes no frame-time, allocation, memory, scrolling, or +performance-multiplier claim for Compose or the native iOS facade. Publish such +claims only with repeatable release benchmarks, representative documents, and +recorded device/target details. diff --git a/docs/iOsNativeSdk.md b/docs/iOsNativeSdk.md index 1802fb1..eeb5a6e 100644 --- a/docs/iOsNativeSdk.md +++ b/docs/iOsNativeSdk.md @@ -2,7 +2,7 @@ ## 1. Feature Overview -The Native iOS SDK exposes CascadeEditor to Swift applications without requiring consumers to use the Kotlin Multiplatform editor API, Compose types, or internal state holders directly. It packages the editor as a dynamic `CascadeEditor.xcframework`, provides a `CascadeEditorController` for document and editing operations, and hosts the Compose editor in a UIKit `UIViewController` that can be embedded in SwiftUI. The bridge also supports native UIKit/SwiftUI custom blocks, native slash commands, localization, complete runtime color palettes, runtime configuration, document import/export, and app-owned formatting chrome. A native Swift sample demonstrates persisted editing, a rich-text comments composer, custom-block integrations, and live family-aware theme switching: the user selects Violet or Forest while iOS appearance automatically selects that family's light/dark variant. +The Native iOS SDK exposes CascadeEditor to Swift applications without requiring consumers to use the Kotlin Multiplatform editor API, Compose types, or internal state holders directly. It packages the editor as a dynamic `CascadeEditor.xcframework`, provides a `CascadeEditorController` for document and editing operations, and hosts the Compose editor in a UIKit `UIViewController` that can be embedded in SwiftUI. For collection surfaces, `CascadeDocumentPreviewController` hosts the bounded static document preview without creating an editor runtime. The bridge also supports native UIKit/SwiftUI custom editor blocks, native slash commands, localization, complete runtime color palettes, runtime configuration, document import/export, and app-owned formatting chrome. A native Swift sample demonstrates persisted editing, a preview grid whose cards open editable documents, a rich-text comments composer, custom-block integrations, and live family-aware theme switching while sharing the same serialized document format as the existing KMP sample. ## 2. Architecture & Design Decisions @@ -44,6 +44,41 @@ The host uses Compose snapshot state and `snapshotFlow` for two bridge streams: Document observation uses `EditorStateHolder.resolveDocumentBlocks(...)`, which folds live text and spans into snapshot blocks without serializing JSON on every edit. A per-composition mount token prevents an old view controller from clearing or publishing bridge state when UIKit/SwiftUI transitions temporarily overlap two hosts created from the same controller. +### Document preview facade + +`CascadeDocumentPreviewController` is the Swift/UIKit entry point for bounded +document summaries. The facade is experimental and its source and binary API +may change before preview mode is stabilized. It owns decoded immutable blocks, a +`CascadeDocumentPreviewConfiguration` snapshot, core localization defaults, and +a dedicated registry seeded with built-in preview renderers. It does not create +`CascadeEditorController`, `EditorStateHolder`, text/span runtime holders, +history, focus, slash-command, drag, or editor mutation infrastructure. + +`makeViewController()` creates a transparent, non-scrolling +`ComposeUIViewController` containing `CascadeDocumentPreview`. The native owner +must give that controller bounded width and height. A reusable list or grid cell +should retain one preview controller and its returned view controller for the +cell lifetime, update it through `loadJson(...)` and +`updateConfiguration(...)`, then release both when the cell is dismantled. +Calling `makeViewController()` on every SwiftUI update would remount the Compose +tree and is outside the intended lifecycle. + +The configuration's `textScale: Double` defaults to `1.0` and multiplies the +host font scale for every `sp` inside the preview, including custom Compose +preview renderers. It does not scale `dp` geometry or explicit host bounds; +text layout remeasures normally. Invalid native values (non-finite, +non-positive, or non-positive/non-finite after conversion to the core `Float`) +normalize to `1.0`. + +The preview facade uses the generic document decoder. A successful JSON load +publishes one new immutable block snapshot; a parse failure returns +`success = false`, reports warning messages, and preserves the currently +displayed snapshot. Unknown block types and opaque custom content remain in the +decoded document and render through the bounded native preview fallback. +Registrations made on `CascadeEditorController` are not shared: native custom +editor renderers never mount in preview cells, and the SDK does not yet expose a +Swift custom preview-provider registration API. + ### Native custom-block adapter `CascadeCustomBlockRegistration` describes a native block's identity, slash-menu metadata, default JSON payload, initial height, and `UIViewController` factory. Registration produces four linked pieces: @@ -105,6 +140,15 @@ The built-in slash executor also distinguishes text and non-text conversion targ `loadHtml(...)` follows the core default HTML profile, hard-replaces the document, and classifies input-limit or decoder-exception warnings as an unsuccessful load. +### Native preview grid to editor + +1. Swift owns a collection of persisted document JSON snapshots and creates one `CascadeDocumentPreviewController` for each visible grid cell. +2. A `UIViewControllerRepresentable` caches the controller returned by `makeViewController()` inside a bounded card; the outer SwiftUI `LazyVGrid` owns scrolling and item identity. +3. Card-level navigation can disable preview links and hit testing so the whole card remains one deliberate tap target. +4. Selecting a card creates a separate `CascadeEditorController`, loads the same JSON, and mounts the full editor screen. +5. The editor exports JSON after edits; the application persists it and republishes the saved snapshot to the grid. +6. The retained preview controller receives the changed JSON through `loadJson(...)`, replacing its immutable snapshot without mounting editor runtime in the grid. + ### App-owned toolbar action 1. Swift reads `toolbarState` or receives `onToolbarStateChanged` to determine availability and active/mixed/inactive styles. @@ -231,6 +275,68 @@ let viewController = controller.makeViewController() controller.onDocumentChanged = { save(controller.exportJson()) } ``` +### `CascadeDocumentPreviewController` + +Construction and hosting: + +```kotlin +CascadeDocumentPreviewController() +CascadeDocumentPreviewController( + configuration: CascadeDocumentPreviewConfiguration, +) +controller.makeViewController(): UIViewController +``` + +Presentation and loading: + +- `loadJson(json): CascadeDocumentLoadResult` decodes a new immutable preview + snapshot. Parse failure preserves the previously displayed blocks. +- `configuration` exposes the current presentation snapshot. +- `updateConfiguration(value)` updates all presentation policy at once. +- `setDarkMode(value)` updates only `isDark`. +- `CascadeDocumentPreviewConfiguration` exposes positive bounded + `maxBlocks`/`maxLinesPerTextBlock`, `textScale`, `textSelectionEnabled`, + `linksEnabled`, `isDark`, and `crashPolicy`. Defaults are four blocks, three + lines, `1.0` text scale, selection off, links enabled, light mode, and + `containAndReport`. Non-positive limits normalize to `1`; invalid text scales + (non-finite, non-positive, or non-positive/non-finite after conversion to the + core `Float`) normalize to `1.0`. + +Callbacks: + +- `onOpenLink: ((String) -> Unit)?` is the only native preview link-opening + path. Without it, links remain visual-only. +- `onInternalError: ((String) -> Unit)?` receives main-thread misuse, contained + renderer failures, and host callback failures. Exceptions thrown by this + reporter are swallowed at the Swift/Objective-C boundary. + +Swift grid integration keeps one controller per live cell: + +```swift +let preview = CascadeDocumentPreviewController( + configuration: CascadeDocumentPreviewConfiguration( + maxBlocks: 4, maxLinesPerTextBlock: 3, + textScale: 0.8, + textSelectionEnabled: false, linksEnabled: false, + isDark: isDark, crashPolicy: .containAndReport + ) +) +let loadResult = preview.loadJson(json: document.json) +guard loadResult.success else { + // Surface or recover the malformed stored document. + return +} +let viewController = preview.makeViewController() +``` + +The sample's `CascadeDocumentPreviewHost` wraps the view controller with +`UIViewControllerRepresentable`. `PreviewGalleryScreen` uses a bounded +`LazyVGrid`; `PreviewDocumentLibrary` seeds and persists document JSON; and +`PreviewDocumentEditorScreen` opens a regular editor, autosaves exported JSON, +flushes pending edits before backgrounding or leaving the screen, and +republishes successful saves so the corresponding preview updates. A failed +Back-button flush keeps the editor open and surfaces the save failure. + ### Custom blocks - `CascadeCustomBlockRegistration(...)` — registers identity, slash metadata/behavior, default JSON-object payload, estimated height, and `(CascadeCustomBlockContext) -> UIViewController` factory. @@ -258,9 +364,9 @@ controller.onDocumentChanged = { save(controller.exportJson()) } - **Core editor state and history:** the controller owns `EditorStateHolder`, `BlockTextStates`, and `BlockSpanStates`; toolbar and custom-block operations reuse core history-aware actions. - **Compose UI:** `ComposeUIViewController` hosts the existing `CascadeEditor`, and Compose snapshot state drives all bridge callbacks. -- **UIKit and SwiftUI:** the SDK returns `UIViewController`; the sample embeds it with `UIViewControllerRepresentable`. Custom blocks also return `UIViewController`, allowing `UIHostingController`-backed SwiftUI content. +- **UIKit and SwiftUI:** both editor and preview facades return `UIViewController`; the sample embeds them with separate `UIViewControllerRepresentable` adapters. Custom editor blocks also return `UIViewController`, allowing `UIHostingController`-backed SwiftUI content. - **Registry system:** native blocks extend `BlockRegistry`; native commands extend `SlashCommandRegistry`. Observable revisions make runtime registration visible to an already-mounted editor. -- **Serialization:** JSON uses `DocumentSchema` plus `NativeCustomBlockCodec`; HTML uses `HtmlProfile.Default`. Both share the same live text/span holders as rendering. +- **Serialization:** editable JSON uses `DocumentSchema` plus `NativeCustomBlockCodec`; HTML uses `HtmlProfile.Default`. Both share the editor's live text/span holders. The preview facade uses `DocumentSchema` directly to preserve generic unknown/custom data without creating runtime holders or resolving native editor types. - **Rich text and toolbar:** the bridge maps `FormattingState`, `IndentationState`, and `LinkState` from `CascadeEditorToolbarController` into Swift-friendly state and actions. - **Localization and theme:** Swift string overrides resolve into core `CascadeEditorStrings` and `CascadeEditorBlockStrings`. `CascadeEditorColors` snapshots map all 24 ARGB slots into the core palette and recompose live. Without a custom palette, `isDark` selects the built-in colors; it remains exposed to native custom blocks in either mode. - **Local build:** `scripts/build-xcframework.sh` assembles the dynamic debug XCFramework at `editor-ios-sdk/build/XCFrameworks/debug/CascadeEditor.xcframework`. The Xcode sample links and embeds this local artifact. @@ -292,6 +398,21 @@ controller.onDocumentChanged = { save(controller.exportJson()) } through SwiftPM; consumers must not add a second manual Embed Frameworks entry. - Binary API dumps validate Kotlin declarations but do not validate `@ObjCName` spelling or every Objective-C header-lowering detail. Swift-visible naming changes require inspection of the generated `CascadeEditor.h`. +- `CascadeDocumentPreviewController` operations and configuration access are + main-thread-only. Guarded off-main calls report `onInternalError` and return a + stable fallback/no-op. +- A preview view controller is transparent and non-scrolling. Its UIKit/SwiftUI + owner must supply bounded dimensions, retain it for the owning cell lifetime, + and let the outer collection own scrolling, reuse, and navigation. +- Preview link activation requires both `linksEnabled` and an explicit + `onOpenLink` callback. The callback is exception-contained; the SDK does not + invoke an implicit platform URL opener. +- Native custom editor registrations do not participate in preview rendering. + Unknown/custom JSON is preserved and shown through the generic bounded + fallback. There is no Swift custom preview-provider API yet, and native + UIKit/SwiftUI editor renderers are never mounted by the preview facade. +- The sample demonstrates the lifecycle and persistence flow, but no manual iOS + memory, scrolling, accessibility, or frame-time evidence is claimed. - ⚠️ Unclear: iOS native Compose text input is explicitly requested by the common text-field call but currently forced off in the iOS platform implementation due to CMP-10404. The TODO does not define the Compose version or acceptance criteria for enabling it. ## 8. Glossary @@ -302,6 +423,9 @@ controller.onDocumentChanged = { save(controller.exportJson()) } - **Hard replacement:** a document load/reset that replaces the editor state, clears runtime holders, and clears undo/redo history. - **Mount token:** identity object used to ensure only the current Compose host publishes or clears controller bridge state. - **Native custom block:** a custom document block whose content is rendered by a UIKit/SwiftUI-owned `UIViewController` hosted inside Compose. +- **Native document preview:** a bounded static Compose preview hosted by + `CascadeDocumentPreviewController`, with decoded immutable blocks and no + editor runtime. - **Native type codec:** `NativeCustomBlockCodec`, which maps registered custom type IDs between persisted JSON and renderable native block types. - **Runtime holders:** `BlockTextStates` and `BlockSpanStates`, which contain live editing values that may be newer than `EditorState` snapshot content. - **Structural action:** a semantic document mutation captured as one full-checkpoint history entry. diff --git a/docs/recipes/PreviewMode.md b/docs/recipes/PreviewMode.md new file mode 100644 index 0000000..d4afff6 --- /dev/null +++ b/docs/recipes/PreviewMode.md @@ -0,0 +1,128 @@ +# Preview Mode + +Use this recipe for a grid or feed of static document summaries where selecting +a card opens the same document in a full editor. + +## Integration contract + +- Store one canonical document JSON payload per stable document ID. +- Decode outside item composition and keep the last valid snapshot when a reload + fails. +- Let the outer grid own scrolling, sizing, taps, and navigation. +- Use bounded previews for collections; do not use a read-only editor as a card. +- After editing, persist first and publish the new snapshot only after the write + succeeds. +- Preview mode is experimental. Keep its use behind a small host adapter. + +## Compose Multiplatform + +1. Decode repository JSON into immutable `List` values before rendering. + Keep both `json` and `blocks` in the screen model so the editor and preview + share one persistence format. + +2. Hoist the registry, theme, and localization objects once, then render cards + with stable keys: + +```kotlin +@OptIn(ExperimentalCascadePreviewApi::class) +@Composable +fun PreviewGrid(documents: List, onEdit: (String) -> Unit) { + val registry = remember { createEditorRegistry() } + val theme = remember { CascadeEditorTheme.light() } + val strings = remember { CascadeEditorStrings.default() } + val blockStrings = remember { CascadeEditorBlockStrings.default() } + + LazyVerticalGrid(columns = GridCells.Adaptive(260.dp)) { + items(documents, key = { it.id }) { document -> + Box(Modifier.clickable { onEdit(document.id) }) { + CascadeDocumentPreview( + blocks = document.blocks, + registry = registry, + theme = theme, + strings = strings, + blockStrings = blockStrings, + config = CascadeDocumentPreviewConfig.GridCard.copy( + textScale = 0.8f, + linksEnabled = false, + ), + onOpenLink = null, + ) + } + } + } +} +``` + +3. Navigate by document ID. Create editor state only on the destination screen, + load that document's JSON, and save the editor export back through the + repository. When the repository publishes new `blocks`, the keyed card + refreshes without replacing the grid. Replace only the edited entry so the + other `blocks` lists keep their identity and their cards do not recompose. + + Register a `BlockTypeCodec` for every custom block type the grid renders. + Without one, a custom block decodes as `UnknownBlockType` after the first + save and silently drops to the generic fallback. + +If links must work inside a card, enable `linksEnabled`, provide `onOpenLink`, +and explicitly arbitrate link taps against card navigation. Register custom +preview renderers with `registerPreviewRenderer`; editor renderers are a +separate channel. + +The multiplatform setting is `textScale: Float = 1f`. For compact two-column +cards, start with `0.8f`. It multiplies the host font scale for every `sp` +inside the preview, including custom renderers. It does not scale `dp` spacing +or explicit host bounds; text layout remeasures normally. + +## Native iOS (Swift/SwiftUI) + +1. Keep seeded and user-edited JSON in an app-owned `ObservableObject` store. + Publish a document update only after its atomic file write succeeds. + +2. Give each live card model one retained preview controller. Load JSON + explicitly so malformed storage is observable: + +```swift +let configuration = CascadeDocumentPreviewConfiguration( + maxBlocks: 4, + maxLinesPerTextBlock: 3, + textScale: 0.8, + textSelectionEnabled: false, + linksEnabled: false, + isDark: isDark, + crashPolicy: .containAndReport +) +let controller = CascadeDocumentPreviewController( + configuration: configuration +) +let result = controller.loadJson(json: document.json) +guard result.success else { + // Surface or recover the malformed stored document. + return +} +``` + +3. Wrap `controller.makeViewController()` in + `UIViewControllerRepresentable`. Create it only in + `makeUIViewController`; update the retained controller from the card model + instead of remounting Compose. Give the host a bounded frame and disable its + hit testing when the outer card owns taps. + +4. Navigate by stable document ID to a separate `CascadeEditorController`. + Debounce ordinary exports, synchronously flush pending edits when the scene + leaves `.active` and before Back, and keep the editor open if persistence + fails. A successful store publication should call `loadJson` on the retained + card controller. + +Keep `onOpenLink` nil when the card owns navigation. Native custom editor views +do not cross into preview mode; unknown/custom JSON uses the bounded generic +fallback. The Swift/native setting is `textScale: Double = 1.0`. Invalid values +(non-finite, non-positive, or non-positive/non-finite after conversion to the +core `Float`) normalize to `1.0`. + +Reference implementations: + +- Multiplatform: `sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/PreviewGalleryScreen.kt` + and `sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/` +- Native iOS: `iosNativeSample/iosNativeSample/Screens/PreviewGalleryScreen.swift` + and `iosNativeSample/iosNativeSample/Screens/Preview/` +- Full behavior and extension contract: `docs/DocumentPreview.md` diff --git a/editor-ios-sdk/api/editor-ios-sdk.klib.api b/editor-ios-sdk/api/editor-ios-sdk.klib.api index 029a293..22d90a4 100644 --- a/editor-ios-sdk/api/editor-ios-sdk.klib.api +++ b/editor-ios-sdk/api/editor-ios-sdk.klib.api @@ -420,6 +420,58 @@ final class io.github.linreal.cascade.ios.model/CascadeRichTextSpan { // io.gith final fun toString(): kotlin/String // io.github.linreal.cascade.ios.model/CascadeRichTextSpan.toString|toString(){}[0] } +final class io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration { // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration|null[0] + constructor () // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.|(){}[0] + constructor (kotlin/Int, kotlin/Int, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, io.github.linreal.cascade.ios.controller/CascadeCrashPolicy) // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.|(kotlin.Int;kotlin.Int;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.ios.controller.CascadeCrashPolicy){}[0] + constructor (kotlin/Int, kotlin/Int, kotlin/Double, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, io.github.linreal.cascade.ios.controller/CascadeCrashPolicy) // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.|(kotlin.Int;kotlin.Int;kotlin.Double;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.ios.controller.CascadeCrashPolicy){}[0] + + final val crashPolicy // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.crashPolicy|{}crashPolicy[0] + final fun (): io.github.linreal.cascade.ios.controller/CascadeCrashPolicy // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.crashPolicy.|(){}[0] + final val isDark // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.isDark|{}isDark[0] + final fun (): kotlin/Boolean // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.isDark.|(){}[0] + final val linksEnabled // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.linksEnabled|{}linksEnabled[0] + final fun (): kotlin/Boolean // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.linksEnabled.|(){}[0] + final val maxBlocks // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.maxBlocks|{}maxBlocks[0] + final fun (): kotlin/Int // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.maxBlocks.|(){}[0] + final val maxLinesPerTextBlock // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.maxLinesPerTextBlock|{}maxLinesPerTextBlock[0] + final fun (): kotlin/Int // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.maxLinesPerTextBlock.|(){}[0] + final val textScale // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.textScale|{}textScale[0] + final fun (): kotlin/Double // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.textScale.|(){}[0] + final val textSelectionEnabled // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.textSelectionEnabled|{}textSelectionEnabled[0] + final fun (): kotlin/Boolean // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.textSelectionEnabled.|(){}[0] + + final fun component1(): kotlin/Int // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.component1|component1(){}[0] + final fun component2(): kotlin/Int // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.component2|component2(){}[0] + final fun component3(): kotlin/Double // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.component3|component3(){}[0] + final fun component4(): kotlin/Boolean // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.component4|component4(){}[0] + final fun component5(): kotlin/Boolean // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.component5|component5(){}[0] + final fun component6(): kotlin/Boolean // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.component6|component6(){}[0] + final fun component7(): io.github.linreal.cascade.ios.controller/CascadeCrashPolicy // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.component7|component7(){}[0] + final fun copy(kotlin/Int = ..., kotlin/Int = ..., kotlin/Double = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., io.github.linreal.cascade.ios.controller/CascadeCrashPolicy = ...): io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.copy|copy(kotlin.Int;kotlin.Int;kotlin.Double;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.ios.controller.CascadeCrashPolicy){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration.toString|toString(){}[0] +} + +final class io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController { // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController|null[0] + constructor () // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.|(){}[0] + constructor (io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration) // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.|(io.github.linreal.cascade.ios.preview.CascadeDocumentPreviewConfiguration){}[0] + + final val configuration // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.configuration|{}configuration[0] + final fun (): io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.configuration.|(){}[0] + + final var onInternalError // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.onInternalError|{}onInternalError[0] + final fun (): kotlin/Function1? // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.onInternalError.|(){}[0] + final fun (kotlin/Function1?) // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.onInternalError.|(kotlin.Function1?){}[0] + final var onOpenLink // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.onOpenLink|{}onOpenLink[0] + final fun (): kotlin/Function1? // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.onOpenLink.|(){}[0] + final fun (kotlin/Function1?) // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.onOpenLink.|(kotlin.Function1?){}[0] + + final fun loadJson(kotlin/String): io.github.linreal.cascade.ios.controller/CascadeDocumentLoadResult // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.loadJson|loadJson(kotlin.String){}[0] + final fun setDarkMode(kotlin/Boolean) // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.setDarkMode|setDarkMode(kotlin.Boolean){}[0] + final fun updateConfiguration(io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewConfiguration) // io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController.updateConfiguration|updateConfiguration(io.github.linreal.cascade.ios.preview.CascadeDocumentPreviewConfiguration){}[0] +} + final class io.github.linreal.cascade.ios.slash/CascadeSlashCommand { // io.github.linreal.cascade.ios.slash/CascadeSlashCommand|null[0] constructor (kotlin/String, kotlin/String, kotlin/String, kotlin.collections/List = ..., kotlin/Function1) // io.github.linreal.cascade.ios.slash/CascadeSlashCommand.|(kotlin.String;kotlin.String;kotlin.String;kotlin.collections.List;kotlin.Function1){}[0] @@ -604,6 +656,8 @@ final val io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_mode final val io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextBlock$stableprop // io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextBlock$stableprop|#static{}io_github_linreal_cascade_ios_model_CascadeRichTextBlock$stableprop[0] final val io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextSnapshot$stableprop // io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextSnapshot$stableprop|#static{}io_github_linreal_cascade_ios_model_CascadeRichTextSnapshot$stableprop[0] final val io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextSpan$stableprop // io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextSpan$stableprop|#static{}io_github_linreal_cascade_ios_model_CascadeRichTextSpan$stableprop[0] +final val io.github.linreal.cascade.ios.preview/io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewConfiguration$stableprop // io.github.linreal.cascade.ios.preview/io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewConfiguration$stableprop|#static{}io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewConfiguration$stableprop[0] +final val io.github.linreal.cascade.ios.preview/io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewController$stableprop // io.github.linreal.cascade.ios.preview/io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewController$stableprop|#static{}io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewController$stableprop[0] final val io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop|#static{}io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop[0] final val io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop|#static{}io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop[0] final val io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop|#static{}io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop[0] @@ -612,6 +666,7 @@ final val io.github.linreal.cascade.ios.toolbar/io_github_linreal_cascade_ios_to final val io.github.linreal.cascade.ios/io_github_linreal_cascade_ios_CascadeEditorSdk$stableprop // io.github.linreal.cascade.ios/io_github_linreal_cascade_ios_CascadeEditorSdk$stableprop|#static{}io_github_linreal_cascade_ios_CascadeEditorSdk$stableprop[0] final fun (io.github.linreal.cascade.ios.controller/CascadeEditorController).io.github.linreal.cascade.ios.controller/makeViewController(): platform.UIKit/UIViewController // io.github.linreal.cascade.ios.controller/makeViewController|makeViewController@io.github.linreal.cascade.ios.controller.CascadeEditorController(){}[0] +final fun (io.github.linreal.cascade.ios.preview/CascadeDocumentPreviewController).io.github.linreal.cascade.ios.preview/makeViewController(): platform.UIKit/UIViewController // io.github.linreal.cascade.ios.preview/makeViewController|makeViewController@io.github.linreal.cascade.ios.preview.CascadeDocumentPreviewController(){}[0] final fun io.github.linreal.cascade.ios.block/io_github_linreal_cascade_ios_block_CascadeBlockBuildOutcome_InvalidPayload$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.block/io_github_linreal_cascade_ios_block_CascadeBlockBuildOutcome_InvalidPayload$stableprop_getter|io_github_linreal_cascade_ios_block_CascadeBlockBuildOutcome_InvalidPayload$stableprop_getter(){}[0] final fun io.github.linreal.cascade.ios.block/io_github_linreal_cascade_ios_block_CascadeBlockBuildOutcome_Success$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.block/io_github_linreal_cascade_ios_block_CascadeBlockBuildOutcome_Success$stableprop_getter|io_github_linreal_cascade_ios_block_CascadeBlockBuildOutcome_Success$stableprop_getter(){}[0] final fun io.github.linreal.cascade.ios.block/io_github_linreal_cascade_ios_block_CascadeBlockBuildOutcome_UnknownType$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.block/io_github_linreal_cascade_ios_block_CascadeBlockBuildOutcome_UnknownType$stableprop_getter|io_github_linreal_cascade_ios_block_CascadeBlockBuildOutcome_UnknownType$stableprop_getter(){}[0] @@ -627,6 +682,8 @@ final fun io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_mode final fun io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextBlock$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextBlock$stableprop_getter|io_github_linreal_cascade_ios_model_CascadeRichTextBlock$stableprop_getter(){}[0] final fun io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextSnapshot$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextSnapshot$stableprop_getter|io_github_linreal_cascade_ios_model_CascadeRichTextSnapshot$stableprop_getter(){}[0] final fun io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextSpan$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.model/io_github_linreal_cascade_ios_model_CascadeRichTextSpan$stableprop_getter|io_github_linreal_cascade_ios_model_CascadeRichTextSpan$stableprop_getter(){}[0] +final fun io.github.linreal.cascade.ios.preview/io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewConfiguration$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.preview/io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewConfiguration$stableprop_getter|io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewConfiguration$stableprop_getter(){}[0] +final fun io.github.linreal.cascade.ios.preview/io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewController$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.preview/io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewController$stableprop_getter|io_github_linreal_cascade_ios_preview_CascadeDocumentPreviewController$stableprop_getter(){}[0] final fun io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop_getter|io_github_linreal_cascade_ios_slash_CascadeSlashCommand$stableprop_getter(){}[0] final fun io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop_getter|io_github_linreal_cascade_ios_slash_CascadeSlashCommandContext$stableprop_getter(){}[0] final fun io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.ios.slash/io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop_getter|io_github_linreal_cascade_ios_slash_CascadeSlashCommandResult$stableprop_getter(){}[0] diff --git a/editor-ios-sdk/build.gradle.kts b/editor-ios-sdk/build.gradle.kts index 2666f11..02c401b 100644 --- a/editor-ios-sdk/build.gradle.kts +++ b/editor-ios-sdk/build.gradle.kts @@ -120,8 +120,14 @@ kotlin { tasks.withType().configureEach { val fixturesDir = projectDir.resolve("src/commonTest/resources") val sampleFilesDir = rootDir.resolve("sample/src/commonMain/composeResources/files") + val iosNativeSampleFilesDir = rootDir.resolve("iosNativeSample/iosNativeSample/Resources") inputs.dir(fixturesDir).withPathSensitivity(PathSensitivity.RELATIVE) inputs.dir(sampleFilesDir).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.dir(iosNativeSampleFilesDir).withPathSensitivity(PathSensitivity.RELATIVE) environment("SIMCTL_CHILD_CASCADE_FIXTURES_DIR", fixturesDir.absolutePath) environment("SIMCTL_CHILD_CASCADE_SAMPLE_FILES_DIR", sampleFilesDir.absolutePath) + environment( + "SIMCTL_CHILD_CASCADE_IOS_NATIVE_SAMPLE_FILES_DIR", + iosNativeSampleFilesDir.absolutePath, + ) } diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorController.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorController.kt index 2e6dc0a..70a0dff 100644 --- a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorController.kt +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorController.kt @@ -215,7 +215,9 @@ public class CascadeEditorController public constructor( if (preflight.warnings.any { warning -> warning is DocumentDecodeWarning.DocumentParseFailed }) { return@onMainThread CascadeDocumentLoadResult( success = false, - warningMessages = preflight.warnings.map { warning -> warning.message() }, + warningMessages = preflight.warnings.map { warning -> + warning.toCascadeWarningMessage() + }, ) } val result = stateHolder.loadFromJson(json, textStates, spanStates, typeCodec = nativeBlockCodec) @@ -229,7 +231,9 @@ public class CascadeEditorController public constructor( } CascadeDocumentLoadResult( success = success, - warningMessages = result.warnings.map { warning -> warning.message() }, + warningMessages = result.warnings.map { warning -> + warning.toCascadeWarningMessage() + }, ) } @@ -804,34 +808,6 @@ public class CascadeEditorController public constructor( return CascadeDocumentLoadResult(success = false, warningMessages = listOf(message)) } - private fun DocumentDecodeWarning.message(): String = when (this) { - is DocumentDecodeWarning.DocumentParseFailed -> "Document parse failed: $reason" - is DocumentDecodeWarning.DuplicateIdRegenerated -> - "Duplicate block id '$originalId' at block $blockIndex was replaced with '$newId'." - - is DocumentDecodeWarning.MissingIdRegenerated -> { - "Missing block id at block $blockIndex was replaced." - } - - is DocumentDecodeWarning.InvalidBlockAttributeParam -> - "Invalid block attribute at block $blockIndex: $param; using $fallback." - - is DocumentDecodeWarning.InvalidBlockTypeParam -> - "Invalid '$param' for block type '$typeId' at block $blockIndex; using $fallback." - - is DocumentDecodeWarning.MalformedBlockSkipped -> - "Malformed block at index $blockIndex was skipped: $reason." - - is DocumentDecodeWarning.UnknownBlockTypePreserved -> - "Unknown block type '$typeId' at block $blockIndex was preserved." - - is DocumentDecodeWarning.UnknownContentKind -> - "Unknown content kind '$kind' at block $blockIndex was preserved as custom content." - - is DocumentDecodeWarning.UnsupportedCustomDataDropped -> - "Unsupported custom value '$key' at block $blockIndex was dropped: $valueType." - } - private fun HtmlDecodeWarning.message(): String = when (this) { is HtmlDecodeWarning.BlockInInlineContext -> "HTML block tag '$tag' at offset $charOffset was flattened in inline content." diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/DocumentDecodeWarningBridge.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/DocumentDecodeWarningBridge.kt new file mode 100644 index 0000000..481b722 --- /dev/null +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/DocumentDecodeWarningBridge.kt @@ -0,0 +1,34 @@ +package io.github.linreal.cascade.ios.controller + +import io.github.linreal.cascade.editor.serialization.DocumentDecodeWarning + +/** + * Keeps Swift-facing document warning text identical across editable and + * preview controllers. + */ +internal fun DocumentDecodeWarning.toCascadeWarningMessage(): String = when (this) { + is DocumentDecodeWarning.DocumentParseFailed -> "Document parse failed: $reason" + is DocumentDecodeWarning.DuplicateIdRegenerated -> + "Duplicate block id '$originalId' at block $blockIndex was replaced with '$newId'." + + is DocumentDecodeWarning.MissingIdRegenerated -> + "Missing block id at block $blockIndex was replaced." + + is DocumentDecodeWarning.InvalidBlockAttributeParam -> + "Invalid block attribute at block $blockIndex: $param; using $fallback." + + is DocumentDecodeWarning.InvalidBlockTypeParam -> + "Invalid '$param' for block type '$typeId' at block $blockIndex; using $fallback." + + is DocumentDecodeWarning.MalformedBlockSkipped -> + "Malformed block at index $blockIndex was skipped: $reason." + + is DocumentDecodeWarning.UnknownBlockTypePreserved -> + "Unknown block type '$typeId' at block $blockIndex was preserved." + + is DocumentDecodeWarning.UnknownContentKind -> + "Unknown content kind '$kind' at block $blockIndex was preserved as custom content." + + is DocumentDecodeWarning.UnsupportedCustomDataDropped -> + "Unsupported custom value '$key' at block $blockIndex was dropped: $valueType." +} diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewConfiguration.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewConfiguration.kt new file mode 100644 index 0000000..9a8bfc0 --- /dev/null +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewConfiguration.kt @@ -0,0 +1,90 @@ +@file:OptIn(ExperimentalObjCName::class) + +package io.github.linreal.cascade.ios.preview + +import io.github.linreal.cascade.editor.CascadeErrorReporter +import io.github.linreal.cascade.editor.theme.CascadeEditorTheme +import io.github.linreal.cascade.editor.ui.CascadeDocumentPreviewConfig +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi +import io.github.linreal.cascade.ios.controller.CascadeCrashPolicy +import io.github.linreal.cascade.ios.controller.toCoreCrashPolicy +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName + +private const val DEFAULT_MAX_BLOCKS: Int = 4 +private const val DEFAULT_MAX_LINES_PER_TEXT_BLOCK: Int = 3 +private const val DEFAULT_TEXT_SCALE: Double = 1.0 + +/** + * Swift-friendly presentation policy for a bounded native document preview. + * + * This facade is experimental. Its source and binary API may change before + * preview mode is stabilized. + * + * The native facade intentionally exposes positive, non-null limits and a + * Swift-native [Double] text scale rather than leaking Compose nullable-number + * or text-overflow types into Swift. Non-positive limits are normalized to + * `1`. A non-positive, non-finite, or core-unrepresentable text scale is + * normalized to `1.0`. Validation therefore never throws across the + * Swift/Objective-C boundary. + */ +@ObjCName("CascadeDocumentPreviewConfiguration", exact = true) +public data class CascadeDocumentPreviewConfiguration( + public val maxBlocks: Int, + public val maxLinesPerTextBlock: Int, + public val textScale: Double, + public val textSelectionEnabled: Boolean, + public val linksEnabled: Boolean, + public val isDark: Boolean, + public val crashPolicy: CascadeCrashPolicy, +) { + /** + * Compatibility initializer for hosts compiled against the original + * bounded-preview configuration shape. + */ + public constructor( + maxBlocks: Int, + maxLinesPerTextBlock: Int, + textSelectionEnabled: Boolean, + linksEnabled: Boolean, + isDark: Boolean, + crashPolicy: CascadeCrashPolicy, + ) : this( + maxBlocks = maxBlocks, + maxLinesPerTextBlock = maxLinesPerTextBlock, + textScale = DEFAULT_TEXT_SCALE, + textSelectionEnabled = textSelectionEnabled, + linksEnabled = linksEnabled, + isDark = isDark, + crashPolicy = crashPolicy, + ) + + public constructor() : this( + maxBlocks = DEFAULT_MAX_BLOCKS, + maxLinesPerTextBlock = DEFAULT_MAX_LINES_PER_TEXT_BLOCK, + textScale = DEFAULT_TEXT_SCALE, + textSelectionEnabled = false, + linksEnabled = true, + isDark = false, + crashPolicy = CascadeCrashPolicy.containAndReport, + ) +} + +@OptIn(ExperimentalCascadePreviewApi::class) +internal fun CascadeDocumentPreviewConfiguration.toCoreConfig( + onInternalError: CascadeErrorReporter? = null, +): CascadeDocumentPreviewConfig = CascadeDocumentPreviewConfig( + maxBlocks = maxBlocks.coerceAtLeast(1), + maxLinesPerTextBlock = maxLinesPerTextBlock.coerceAtLeast(1), + textScale = textScale.toCoreTextScale(), + textSelectionEnabled = textSelectionEnabled, + linksEnabled = linksEnabled, + crashPolicy = crashPolicy.toCoreCrashPolicy(), + onInternalError = onInternalError, +) + +private fun Double.toCoreTextScale(): Float = + toFloat().takeIf { it.isFinite() && it > 0f } ?: DEFAULT_TEXT_SCALE.toFloat() + +internal fun CascadeDocumentPreviewConfiguration.resolveEditorTheme(): CascadeEditorTheme = + if (isDark) CascadeEditorTheme.dark() else CascadeEditorTheme.light() diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewController.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewController.kt new file mode 100644 index 0000000..e556205 --- /dev/null +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewController.kt @@ -0,0 +1,174 @@ +@file:OptIn(ExperimentalObjCName::class) + +package io.github.linreal.cascade.ios.preview + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.registry.BlockRegistry +import io.github.linreal.cascade.editor.serialization.DocumentDecodeWarning +import io.github.linreal.cascade.editor.serialization.DocumentSchema +import io.github.linreal.cascade.editor.theme.CascadeEditorBlockStrings +import io.github.linreal.cascade.editor.theme.CascadeEditorStrings +import io.github.linreal.cascade.editor.ui.createEditorRegistry +import io.github.linreal.cascade.ios.controller.CascadeDocumentLoadResult +import io.github.linreal.cascade.ios.controller.toCascadeWarningMessage +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName +import platform.Foundation.NSThread +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_main_queue + +private const val MAIN_THREAD_ERROR: String = + "CascadeDocumentPreviewController must be used on the main thread" + +/** + * Lightweight Swift/UIKit facade over the static Compose document preview. + * + * This facade is experimental. Its source and binary API may change before + * preview mode is stabilized. + * + * This controller owns only decoded immutable blocks, preview configuration, + * localization defaults, and a dedicated preview-renderer registry. It never + * creates editor state, text/span runtime holders, history, focus, or an + * editable controller. + * + * Native custom editor registrations are deliberately not shared with this + * controller. Unknown/custom blocks use the bounded core preview fallback; + * editor renderers and UIKit custom editor views are never mounted implicitly. + */ +@ObjCName("CascadeDocumentPreviewController", exact = true) +public class CascadeDocumentPreviewController public constructor( + configuration: CascadeDocumentPreviewConfiguration, +) { + public constructor() : this(configuration = CascadeDocumentPreviewConfiguration()) + + internal val blocksSnapshot: MutableState> = mutableStateOf(emptyList()) + internal val configurationSnapshot: MutableState = + mutableStateOf(configuration) + internal val registry: BlockRegistry = createEditorRegistry() + internal val strings: CascadeEditorStrings = CascadeEditorStrings.default() + internal val blockStrings: CascadeEditorBlockStrings = CascadeEditorBlockStrings.default() + internal val hasLinkOpenerSnapshot: MutableState = mutableStateOf(false) + + private var currentConfiguration: CascadeDocumentPreviewConfiguration = configuration + private var openLinkCallback: ((String) -> Unit)? = null + private var internalErrorCallback: ((String) -> Unit)? = null + + public val configuration: CascadeDocumentPreviewConfiguration + get() = onMainThread( + fallback = { CascadeDocumentPreviewConfiguration() }, + block = { currentConfiguration }, + ) + + public var onOpenLink: ((String) -> Unit)? + get() = onMainThread( + fallback = { null }, + block = { openLinkCallback }, + ) + set(value) { + onMainThread(fallback = {}) { + openLinkCallback = value + hasLinkOpenerSnapshot.value = value != null + } + } + + public var onInternalError: ((String) -> Unit)? + get() = onMainThread( + fallback = { null }, + block = { internalErrorCallback }, + ) + set(value) { + onMainThread(fallback = {}) { + internalErrorCallback = value + } + } + + /** + * Replaces the immutable preview snapshot with a decoded JSON document. + * + * A parse failure returns `success = false` and preserves the currently + * displayed document. Non-fatal decode warnings are returned alongside the + * successfully decoded snapshot. + */ + public fun loadJson(json: String): CascadeDocumentLoadResult = onMainThread( + fallback = { + CascadeDocumentLoadResult( + success = false, + warningMessages = listOf(MAIN_THREAD_ERROR), + ) + }, + ) { + val result = DocumentSchema.decodeFromStringWithReport(json) + val failed = result.warnings.any { warning -> + warning is DocumentDecodeWarning.DocumentParseFailed + } + if (!failed) { + blocksSnapshot.value = result.blocks + } + CascadeDocumentLoadResult( + success = !failed, + warningMessages = result.warnings.map { warning -> + warning.toCascadeWarningMessage() + }, + ) + } + + /** Replaces the presentation configuration observed by a mounted preview. */ + public fun updateConfiguration(value: CascadeDocumentPreviewConfiguration): Unit = + onMainThread(fallback = {}) { + updateConfigurationOnMain(value) + } + + /** Convenience update for hosts following a native light/dark theme. */ + public fun setDarkMode(value: Boolean): Unit = onMainThread(fallback = {}) { + updateConfigurationOnMain(currentConfiguration.copy(isDark = value)) + } + + internal fun openLink(target: String): Unit = onMainThread(fallback = {}) { + val callback = openLinkCallback ?: return@onMainThread + try { + callback(target) + } catch (throwable: Throwable) { + reportInternalError( + "CascadeDocumentPreviewController callback onOpenLink failed: " + + (throwable.message ?: throwable.toString()) + ) + } + } + + internal inline fun onMainThread( + fallback: () -> T, + block: () -> T, + ): T { + if (!NSThread.isMainThread) { + reportInternalError(MAIN_THREAD_ERROR) + return fallback() + } + return block() + } + + internal fun reportInternalError(message: String): Unit { + if (NSThread.isMainThread) { + deliverInternalError(message) + } else { + dispatch_async(dispatch_get_main_queue()) { + deliverInternalError(message) + } + } + } + + private fun deliverInternalError(message: String): Unit { + try { + internalErrorCallback?.invoke(message) + } catch (_: Throwable) { + // Host error-reporting callbacks must never escape into Swift/Obj-C. + } + } + + private fun updateConfigurationOnMain(value: CascadeDocumentPreviewConfiguration): Unit { + if (currentConfiguration == value) return + currentConfiguration = value + configurationSnapshot.value = value + } +} diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewViewController.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewViewController.kt new file mode 100644 index 0000000..bbf9016 --- /dev/null +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewViewController.kt @@ -0,0 +1,68 @@ +package io.github.linreal.cascade.ios.preview + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.ComposeUIViewController +import io.github.linreal.cascade.editor.ui.CascadeDocumentPreview +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi +import io.github.linreal.cascade.ios.resources.CascadeEditorResourceReader +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.LocalResourceReader +import platform.UIKit.UIViewController + +/** + * Creates one transparent, non-scrolling UIKit host for this preview. + * + * This facade is experimental. Its source and binary API may change before + * preview mode is stabilized. + * + * The native owner must provide bounded width and height constraints. Keep the + * returned controller for that owning view's lifetime and release it when a + * reusable list/grid cell is dismantled. + */ +@OptIn( + ExperimentalCascadePreviewApi::class, + ExperimentalComposeUiApi::class, + ExperimentalResourceApi::class, +) +public fun CascadeDocumentPreviewController.makeViewController(): UIViewController = onMainThread( + fallback = { UIViewController() }, +) { + ComposeUIViewController(configure = { opaque = false }) { + val blocks by blocksSnapshot + val configurationState by configurationSnapshot + val hasLinkOpener by hasLinkOpenerSnapshot + val previewConfig = remember(configurationState) { + configurationState.toCoreConfig { error -> + reportInternalError( + "CascadeDocumentPreview ${error.context} failed: " + + (error.cause.message ?: error.cause.toString()) + ) + } + } + val theme = remember(configurationState.isDark) { + configurationState.resolveEditorTheme() + } + + CompositionLocalProvider(LocalResourceReader provides CascadeEditorResourceReader) { + CascadeDocumentPreview( + blocks = blocks, + registry = registry, + theme = theme, + strings = strings, + blockStrings = blockStrings, + config = previewConfig, + onOpenLink = if (hasLinkOpener) { + { target -> openLink(target) } + } else { + null + }, + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/fixtures/GoldenParityTest.kt b/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/fixtures/GoldenParityTest.kt index 825333d..13fb68c 100644 --- a/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/fixtures/GoldenParityTest.kt +++ b/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/fixtures/GoldenParityTest.kt @@ -94,6 +94,36 @@ class GoldenParityTest { private fun readFixture(fileName: String): String = readFile("CASCADE_FIXTURES_DIR", fileName) + @Test + fun nativePreviewGalleryDocumentsRoundTripThroughDocumentSchema() { + val previewFiles = listOf( + "preview_product_brief.json", + "preview_weekly_plan.json", + "preview_research_notes.json", + "preview_release_checklist.json", + "preview_code_review.json", + "preview_trip_ideas.json", + ) + + previewFiles.forEach { fileName -> + val fixture = readFile("CASCADE_IOS_NATIVE_SAMPLE_FILES_DIR", fileName) + val decoded = DocumentSchema.decodeFromStringWithReport(fixture) + assertEquals( + emptyList(), + decoded.warnings, + "$fileName must decode without warnings", + ) + assertEquals( + normalizeDocument(fixture, ignoreIds = false), + normalizeDocument( + DocumentSchema.encodeToString(decoded.blocks), + ignoreIds = false, + ), + "$fileName is not canonical DocumentSchema JSON", + ) + } + } + // Editor demo document @Test diff --git a/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewControllerTest.kt b/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewControllerTest.kt new file mode 100644 index 0000000..8fdc7b8 --- /dev/null +++ b/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/preview/CascadeDocumentPreviewControllerTest.kt @@ -0,0 +1,358 @@ +@file:OptIn(io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi::class) + +package io.github.linreal.cascade.ios.preview + +import androidx.compose.ui.text.style.TextOverflow +import io.github.linreal.cascade.editor.CrashPolicy +import io.github.linreal.cascade.editor.core.BlockContent +import io.github.linreal.cascade.editor.core.UnknownBlockType +import io.github.linreal.cascade.ios.controller.CascadeCrashPolicy +import io.github.linreal.cascade.ios.model.CascadeEditorDocumentBuilder +import platform.Foundation.NSDate +import platform.Foundation.NSRunLoop +import platform.Foundation.NSThread +import platform.Foundation.dateWithTimeIntervalSinceNow +import platform.Foundation.runUntilDate +import platform.darwin.DISPATCH_QUEUE_PRIORITY_DEFAULT +import platform.darwin.DISPATCH_TIME_FOREVER +import platform.darwin.dispatch_async +import platform.darwin.dispatch_get_global_queue +import platform.darwin.dispatch_semaphore_create +import platform.darwin.dispatch_semaphore_signal +import platform.darwin.dispatch_semaphore_wait +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class CascadeDocumentPreviewControllerTest { + @Test + fun defaultConfigurationMapsToBoundedCoreGridCardPolicy() { + val configuration = CascadeDocumentPreviewConfiguration() + + assertEquals(4, configuration.maxBlocks) + assertEquals(3, configuration.maxLinesPerTextBlock) + assertEquals(1.0, configuration.textScale) + assertFalse(configuration.textSelectionEnabled) + assertTrue(configuration.linksEnabled) + assertFalse(configuration.isDark) + assertEquals(CascadeCrashPolicy.containAndReport, configuration.crashPolicy) + + val core = configuration.toCoreConfig() + + assertEquals(4, core.maxBlocks) + assertEquals(3, core.maxLinesPerTextBlock) + assertEquals(1f, core.textScale) + assertEquals(TextOverflow.Ellipsis, core.textOverflow) + assertFalse(core.textSelectionEnabled) + assertTrue(core.linksEnabled) + assertEquals(CrashPolicy.ContainAndReport, core.crashPolicy) + } + + @Test + fun legacySwiftFacingInitializerKeepsTheDefaultTextScale() { + val configuration = CascadeDocumentPreviewConfiguration( + maxBlocks = 2, + maxLinesPerTextBlock = 1, + textSelectionEnabled = true, + linksEnabled = false, + isDark = true, + crashPolicy = CascadeCrashPolicy.rethrow, + ) + + assertEquals(1.0, configuration.textScale) + assertEquals(1f, configuration.toCoreConfig().textScale) + } + + @Test + fun explicitConfigurationMapsEverySwiftFacingPreviewPolicy() { + val configuration = CascadeDocumentPreviewConfiguration( + maxBlocks = 2, + maxLinesPerTextBlock = 1, + textScale = 0.8, + textSelectionEnabled = true, + linksEnabled = false, + isDark = true, + crashPolicy = CascadeCrashPolicy.rethrow, + ) + + val core = configuration.toCoreConfig() + + assertEquals(2, core.maxBlocks) + assertEquals(1, core.maxLinesPerTextBlock) + assertEquals(0.8f, core.textScale) + assertEquals(TextOverflow.Ellipsis, core.textOverflow) + assertTrue(core.textSelectionEnabled) + assertFalse(core.linksEnabled) + assertEquals(CrashPolicy.Rethrow, core.crashPolicy) + } + + @Test + fun nonPositiveSwiftLimitsNormalizeToSafePositiveBounds() { + val configuration = CascadeDocumentPreviewConfiguration( + maxBlocks = 0, + maxLinesPerTextBlock = -12, + textScale = 0.75, + textSelectionEnabled = false, + linksEnabled = true, + isDark = false, + crashPolicy = CascadeCrashPolicy.containAndReport, + ) + + val core = configuration.toCoreConfig() + + assertEquals(1, core.maxBlocks) + assertEquals(1, core.maxLinesPerTextBlock) + assertEquals(0.75f, core.textScale) + } + + @Test + fun invalidOrCoreUnrepresentableSwiftTextScalesNormalizeToOne() { + listOf( + 0.0, + -1.0, + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + Double.MIN_VALUE, + Double.MAX_VALUE, + ).forEach { invalidScale -> + val core = CascadeDocumentPreviewConfiguration() + .copy(textScale = invalidScale) + .toCoreConfig() + + assertEquals(1f, core.textScale, "textScale=$invalidScale") + } + } + + @Test + fun validJsonLoadReplacesTheImmutablePreviewSnapshot() { + val controller = CascadeDocumentPreviewController() + val json = CascadeEditorDocumentBuilder() + .heading(level = 2, text = "Field notes") + .paragraph("Previewed without editor runtime") + .buildJson() + + val result = controller.loadJson(json) + + assertTrue(result.success) + assertEquals(2, controller.blocksSnapshot.value.size) + assertEquals( + "Field notes", + assertIs(controller.blocksSnapshot.value[0].content).text, + ) + assertEquals( + "Previewed without editor runtime", + assertIs(controller.blocksSnapshot.value[1].content).text, + ) + } + + @Test + fun malformedJsonPreservesThePreviouslyLoadedPreviewSnapshot() { + val initialJson = CascadeEditorDocumentBuilder() + .paragraph("Keep this preview") + .buildJson() + val controller = CascadeDocumentPreviewController() + assertTrue(controller.loadJson(initialJson).success) + val originalBlocks = controller.blocksSnapshot.value + + val result = controller.loadJson("{ not valid json") + + assertFalse(result.success) + assertTrue(result.warningMessages.isNotEmpty()) + assertSame(originalBlocks, controller.blocksSnapshot.value) + assertEquals( + "Keep this preview", + assertIs(controller.blocksSnapshot.value.single().content).text, + ) + } + + @Test + fun runtimeConfigurationUpdatesPublishOneCompleteSnapshot() { + val controller = CascadeDocumentPreviewController() + val updated = CascadeDocumentPreviewConfiguration( + maxBlocks = 6, + maxLinesPerTextBlock = 2, + textScale = 0.7, + textSelectionEnabled = true, + linksEnabled = false, + isDark = true, + crashPolicy = CascadeCrashPolicy.rethrow, + ) + + controller.updateConfiguration(updated) + + assertEquals(updated, controller.configuration) + assertEquals(updated, controller.configurationSnapshot.value) + } + + @Test + fun setDarkModePreservesEveryOtherPreviewSetting() { + val initial = CascadeDocumentPreviewConfiguration( + maxBlocks = 7, + maxLinesPerTextBlock = 5, + textScale = 0.65, + textSelectionEnabled = true, + linksEnabled = false, + isDark = false, + crashPolicy = CascadeCrashPolicy.rethrow, + ) + val controller = CascadeDocumentPreviewController(configuration = initial) + + controller.setDarkMode(true) + + assertEquals(initial.copy(isDark = true), controller.configuration) + assertEquals(initial.copy(isDark = true), controller.configurationSnapshot.value) + } + + @Test + fun linkCallbackCapabilityTracksRegistrationAndDelegatesTheTarget() { + val controller = CascadeDocumentPreviewController() + var openedTarget: String? = null + + assertFalse(controller.hasLinkOpenerSnapshot.value) + + controller.onOpenLink = { target -> openedTarget = target } + controller.openLink("https://example.com/field-note") + + assertTrue(controller.hasLinkOpenerSnapshot.value) + assertEquals("https://example.com/field-note", openedTarget) + + controller.onOpenLink = null + + assertFalse(controller.hasLinkOpenerSnapshot.value) + } + + @Test + fun linkCallbackFailureIsContainedAndReported() { + val controller = CascadeDocumentPreviewController() + var internalError: String? = null + controller.onOpenLink = { error("host link callback failed") } + controller.onInternalError = { message -> internalError = message } + + controller.openLink("https://example.com") + + val error = assertIs(internalError) + assertTrue(error.contains("onOpenLink")) + assertTrue(error.contains("host link callback failed")) + } + + @Test + fun internalErrorCallbackFailureCannotEscapeTheNativeBoundary() { + val controller = CascadeDocumentPreviewController() + var callbackInvoked = false + controller.onInternalError = { + callbackInvoked = true + error("telemetry failed") + } + + controller.reportInternalError("contained preview failure") + + assertTrue(callbackInvoked) + } + + @Test + fun callbackPropertiesRejectOffMainAccessAndPreserveRegisteredCallbacks() { + val controller = CascadeDocumentPreviewController() + val errors = mutableListOf() + val deliveryThreads = mutableListOf() + val linkCallback: (String) -> Unit = {} + val errorCallback: (String) -> Unit = { message -> + errors += message + deliveryThreads += NSThread.isMainThread + } + controller.onOpenLink = linkCallback + controller.onInternalError = errorCallback + + val offMainCallbacks = runOffMain { + controller.onOpenLink to controller.onInternalError + } + runOffMain { + controller.onOpenLink = {} + controller.onInternalError = {} + } + + assertNull(offMainCallbacks.first) + assertNull(offMainCallbacks.second) + assertSame(linkCallback, controller.onOpenLink) + assertSame(errorCallback, controller.onInternalError) + assertTrue(controller.hasLinkOpenerSnapshot.value) + + drainMainQueue { errors.size == 4 } + + assertEquals( + List(4) { "CascadeDocumentPreviewController must be used on the main thread" }, + errors, + ) + assertTrue(deliveryThreads.all { deliveredOnMain -> deliveredOnMain }) + } + + @Test + fun unknownCustomDocumentDataIsPreservedForTheSafePreviewFallback() { + val controller = CascadeDocumentPreviewController() + val json = + """ + { + "version": 2, + "blocks": [ + { + "id": "metric-1", + "type": { + "typeId": "metric", + "custom": true, + "schemaVersion": 3 + }, + "content": { + "kind": "metric", + "data": { + "label": "Adoption", + "value": 42 + } + } + } + ] + } + """.trimIndent() + + val result = controller.loadJson(json) + + assertTrue(result.success) + assertTrue(result.warningMessages.any { warning -> warning.contains("metric") }) + val block = controller.blocksSnapshot.value.single() + val type = assertIs(block.type) + assertEquals("metric", type.typeId) + assertTrue(type.rawTypeJson.contains("\"schemaVersion\":3")) + val content = assertIs(block.content) + assertEquals("metric", content.typeId) + assertEquals("Adoption", content.data["label"]) + assertEquals(42L, content.data["value"]) + } + + /** + * Pumps the main run loop until [until] holds or the timeout elapses, so + * off-main misuse reports dispatched to the main queue can be delivered. + */ + private fun drainMainQueue(timeoutSeconds: Double = 2.0, until: () -> Boolean) { + val runLoop = NSRunLoop.mainRunLoop + var elapsed = 0.0 + val step = 0.02 + while (!until() && elapsed < timeoutSeconds) { + runLoop.runUntilDate(NSDate.dateWithTimeIntervalSinceNow(step)) + elapsed += step + } + } + + private fun runOffMain(block: () -> T): T { + val semaphore = dispatch_semaphore_create(0) + var result: Result? = null + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(), 0u)) { + result = runCatching(block) + dispatch_semaphore_signal(semaphore) + } + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER) + return result!!.getOrThrow() + } +} diff --git a/editor/api/android/editor.api b/editor/api/android/editor.api index 65beff0..8723b75 100644 --- a/editor/api/android/editor.api +++ b/editor/api/android/editor.api @@ -2481,6 +2481,18 @@ public final class io/github/linreal/cascade/editor/registry/BlockDescriptor { public fun toString ()Ljava/lang/String; } +public abstract interface class io/github/linreal/cascade/editor/registry/BlockPreviewRenderer { + public abstract fun RenderPreview (Lio/github/linreal/cascade/editor/core/Block;Landroidx/compose/ui/Modifier;Lio/github/linreal/cascade/editor/registry/BlockPreviewScope;Landroidx/compose/runtime/Composer;I)V +} + +public abstract interface class io/github/linreal/cascade/editor/registry/BlockPreviewScope { + public abstract fun getBlock-SDe6QqM (Ljava/lang/String;)Lio/github/linreal/cascade/editor/core/Block; + public abstract fun getBlocks ()Ljava/util/List; + public abstract fun getCanOpenLinks ()Z + public abstract fun getConfig ()Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; + public abstract fun openLink (Ljava/lang/String;)V +} + public final class io/github/linreal/cascade/editor/registry/BlockRegistry { public static final field $stable I public static final field Companion Lio/github/linreal/cascade/editor/registry/BlockRegistry$Companion; @@ -2488,13 +2500,17 @@ public final class io/github/linreal/cascade/editor/registry/BlockRegistry { public final fun createBlock (Ljava/lang/String;)Lio/github/linreal/cascade/editor/core/Block; public final fun getAllDescriptors ()Ljava/util/List; public final fun getDescriptor (Ljava/lang/String;)Lio/github/linreal/cascade/editor/registry/BlockDescriptor; + public final fun getPreviewRenderer (Lio/github/linreal/cascade/editor/core/BlockType;)Lio/github/linreal/cascade/editor/registry/BlockPreviewRenderer; + public final fun getPreviewRenderer (Ljava/lang/String;)Lio/github/linreal/cascade/editor/registry/BlockPreviewRenderer; public final fun getRenderer (Lio/github/linreal/cascade/editor/core/BlockType;)Lio/github/linreal/cascade/editor/registry/BlockRenderer; public final fun getRenderer (Ljava/lang/String;)Lio/github/linreal/cascade/editor/registry/BlockRenderer; public final fun isRegistered (Ljava/lang/String;)Z public final fun register (Lio/github/linreal/cascade/editor/registry/BlockDescriptor;Lio/github/linreal/cascade/editor/registry/BlockRenderer;)V public final fun registerDescriptor (Lio/github/linreal/cascade/editor/registry/BlockDescriptor;)V + public final fun registerPreviewRenderer (Ljava/lang/String;Lio/github/linreal/cascade/editor/registry/BlockPreviewRenderer;)V public final fun registerRenderer (Ljava/lang/String;Lio/github/linreal/cascade/editor/registry/BlockRenderer;)V public final fun search (Ljava/lang/String;)Ljava/util/List; + public final fun setUnknownBlockPreviewRenderer (Lio/github/linreal/cascade/editor/registry/BlockPreviewRenderer;)V public final fun setUnknownBlockRenderer (Lio/github/linreal/cascade/editor/registry/BlockRenderer;)V } @@ -3641,6 +3657,44 @@ public final class io/github/linreal/cascade/editor/ui/BlockGestureModifierKt { public static synthetic fun draggableAfterLongPress$default (Landroidx/compose/ui/Modifier;Ljava/lang/Object;ZLjava/lang/Long;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; } +public final class io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig { + public static final field $stable I + public static final field Companion Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig$Companion; + public synthetic fun (Ljava/lang/Integer;Ljava/lang/Integer;FIZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/Integer;Ljava/lang/Integer;FIZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Integer; + public final fun component2 ()Ljava/lang/Integer; + public final fun component3 ()F + public final fun component4-gIe3tQ8 ()I + public final fun component5 ()Z + public final fun component6 ()Z + public final fun component7 ()Lio/github/linreal/cascade/editor/CrashPolicy; + public final fun component8 ()Lkotlin/jvm/functions/Function1; + public final fun copy-ZNqEYIc (Ljava/lang/Integer;Ljava/lang/Integer;FIZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;)Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; + public static synthetic fun copy-ZNqEYIc$default (Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig;Ljava/lang/Integer;Ljava/lang/Integer;FIZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getCrashPolicy ()Lio/github/linreal/cascade/editor/CrashPolicy; + public final fun getLinksEnabled ()Z + public final fun getMaxBlocks ()Ljava/lang/Integer; + public final fun getMaxLinesPerTextBlock ()Ljava/lang/Integer; + public final fun getOnInternalError ()Lkotlin/jvm/functions/Function1; + public final fun getTextOverflow-gIe3tQ8 ()I + public final fun getTextScale ()F + public final fun getTextSelectionEnabled ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig$Companion { + public final fun getDefault ()Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; + public final fun getGridCard ()Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; + public final fun getUnbounded ()Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; +} + +public final class io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewKt { + public static final fun CascadeDocumentPreview (Ljava/util/List;Landroidx/compose/ui/Modifier;Lio/github/linreal/cascade/editor/registry/BlockRegistry;Lio/github/linreal/cascade/editor/theme/CascadeEditorTheme;Lio/github/linreal/cascade/editor/theme/CascadeEditorStrings;Lio/github/linreal/cascade/editor/theme/CascadeEditorBlockStrings;Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + public final class io/github/linreal/cascade/editor/ui/CascadeEditorConfig { public static final field $stable I public static final field Companion Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig$Companion; @@ -3693,6 +3747,9 @@ public final class io/github/linreal/cascade/editor/ui/EditorRegistryKt { public static final fun registerBuiltInRenderers (Lio/github/linreal/cascade/editor/registry/BlockRegistry;)V } +public abstract interface annotation class io/github/linreal/cascade/editor/ui/ExperimentalCascadePreviewApi : java/lang/annotation/Annotation { +} + public final class io/github/linreal/cascade/editor/ui/HideKeyboardToolbarButtonKt { public static final fun HideKeyboardToolbarButton (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } diff --git a/editor/api/desktop/editor.api b/editor/api/desktop/editor.api index 65beff0..8723b75 100644 --- a/editor/api/desktop/editor.api +++ b/editor/api/desktop/editor.api @@ -2481,6 +2481,18 @@ public final class io/github/linreal/cascade/editor/registry/BlockDescriptor { public fun toString ()Ljava/lang/String; } +public abstract interface class io/github/linreal/cascade/editor/registry/BlockPreviewRenderer { + public abstract fun RenderPreview (Lio/github/linreal/cascade/editor/core/Block;Landroidx/compose/ui/Modifier;Lio/github/linreal/cascade/editor/registry/BlockPreviewScope;Landroidx/compose/runtime/Composer;I)V +} + +public abstract interface class io/github/linreal/cascade/editor/registry/BlockPreviewScope { + public abstract fun getBlock-SDe6QqM (Ljava/lang/String;)Lio/github/linreal/cascade/editor/core/Block; + public abstract fun getBlocks ()Ljava/util/List; + public abstract fun getCanOpenLinks ()Z + public abstract fun getConfig ()Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; + public abstract fun openLink (Ljava/lang/String;)V +} + public final class io/github/linreal/cascade/editor/registry/BlockRegistry { public static final field $stable I public static final field Companion Lio/github/linreal/cascade/editor/registry/BlockRegistry$Companion; @@ -2488,13 +2500,17 @@ public final class io/github/linreal/cascade/editor/registry/BlockRegistry { public final fun createBlock (Ljava/lang/String;)Lio/github/linreal/cascade/editor/core/Block; public final fun getAllDescriptors ()Ljava/util/List; public final fun getDescriptor (Ljava/lang/String;)Lio/github/linreal/cascade/editor/registry/BlockDescriptor; + public final fun getPreviewRenderer (Lio/github/linreal/cascade/editor/core/BlockType;)Lio/github/linreal/cascade/editor/registry/BlockPreviewRenderer; + public final fun getPreviewRenderer (Ljava/lang/String;)Lio/github/linreal/cascade/editor/registry/BlockPreviewRenderer; public final fun getRenderer (Lio/github/linreal/cascade/editor/core/BlockType;)Lio/github/linreal/cascade/editor/registry/BlockRenderer; public final fun getRenderer (Ljava/lang/String;)Lio/github/linreal/cascade/editor/registry/BlockRenderer; public final fun isRegistered (Ljava/lang/String;)Z public final fun register (Lio/github/linreal/cascade/editor/registry/BlockDescriptor;Lio/github/linreal/cascade/editor/registry/BlockRenderer;)V public final fun registerDescriptor (Lio/github/linreal/cascade/editor/registry/BlockDescriptor;)V + public final fun registerPreviewRenderer (Ljava/lang/String;Lio/github/linreal/cascade/editor/registry/BlockPreviewRenderer;)V public final fun registerRenderer (Ljava/lang/String;Lio/github/linreal/cascade/editor/registry/BlockRenderer;)V public final fun search (Ljava/lang/String;)Ljava/util/List; + public final fun setUnknownBlockPreviewRenderer (Lio/github/linreal/cascade/editor/registry/BlockPreviewRenderer;)V public final fun setUnknownBlockRenderer (Lio/github/linreal/cascade/editor/registry/BlockRenderer;)V } @@ -3641,6 +3657,44 @@ public final class io/github/linreal/cascade/editor/ui/BlockGestureModifierKt { public static synthetic fun draggableAfterLongPress$default (Landroidx/compose/ui/Modifier;Ljava/lang/Object;ZLjava/lang/Long;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; } +public final class io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig { + public static final field $stable I + public static final field Companion Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig$Companion; + public synthetic fun (Ljava/lang/Integer;Ljava/lang/Integer;FIZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/Integer;Ljava/lang/Integer;FIZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Integer; + public final fun component2 ()Ljava/lang/Integer; + public final fun component3 ()F + public final fun component4-gIe3tQ8 ()I + public final fun component5 ()Z + public final fun component6 ()Z + public final fun component7 ()Lio/github/linreal/cascade/editor/CrashPolicy; + public final fun component8 ()Lkotlin/jvm/functions/Function1; + public final fun copy-ZNqEYIc (Ljava/lang/Integer;Ljava/lang/Integer;FIZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;)Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; + public static synthetic fun copy-ZNqEYIc$default (Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig;Ljava/lang/Integer;Ljava/lang/Integer;FIZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getCrashPolicy ()Lio/github/linreal/cascade/editor/CrashPolicy; + public final fun getLinksEnabled ()Z + public final fun getMaxBlocks ()Ljava/lang/Integer; + public final fun getMaxLinesPerTextBlock ()Ljava/lang/Integer; + public final fun getOnInternalError ()Lkotlin/jvm/functions/Function1; + public final fun getTextOverflow-gIe3tQ8 ()I + public final fun getTextScale ()F + public final fun getTextSelectionEnabled ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig$Companion { + public final fun getDefault ()Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; + public final fun getGridCard ()Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; + public final fun getUnbounded ()Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig; +} + +public final class io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewKt { + public static final fun CascadeDocumentPreview (Ljava/util/List;Landroidx/compose/ui/Modifier;Lio/github/linreal/cascade/editor/registry/BlockRegistry;Lio/github/linreal/cascade/editor/theme/CascadeEditorTheme;Lio/github/linreal/cascade/editor/theme/CascadeEditorStrings;Lio/github/linreal/cascade/editor/theme/CascadeEditorBlockStrings;Lio/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + public final class io/github/linreal/cascade/editor/ui/CascadeEditorConfig { public static final field $stable I public static final field Companion Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig$Companion; @@ -3693,6 +3747,9 @@ public final class io/github/linreal/cascade/editor/ui/EditorRegistryKt { public static final fun registerBuiltInRenderers (Lio/github/linreal/cascade/editor/registry/BlockRegistry;)V } +public abstract interface annotation class io/github/linreal/cascade/editor/ui/ExperimentalCascadePreviewApi : java/lang/annotation/Annotation { +} + public final class io/github/linreal/cascade/editor/ui/HideKeyboardToolbarButtonKt { public static final fun HideKeyboardToolbarButton (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } diff --git a/editor/api/editor.klib.api b/editor/api/editor.klib.api index 3474276..c0dd4af 100644 --- a/editor/api/editor.klib.api +++ b/editor/api/editor.klib.api @@ -14,6 +14,10 @@ open annotation class io.github.linreal.cascade.editor.markdown/ExperimentalCasc constructor () // io.github.linreal.cascade.editor.markdown/ExperimentalCascadeMarkdownApi.|(){}[0] } +open annotation class io.github.linreal.cascade.editor.ui/ExperimentalCascadePreviewApi : kotlin/Annotation { // io.github.linreal.cascade.editor.ui/ExperimentalCascadePreviewApi|null[0] + constructor () // io.github.linreal.cascade.editor.ui/ExperimentalCascadePreviewApi.|(){}[0] +} + final enum class io.github.linreal.cascade.editor.markdown/MarkdownCodecLimitKind : kotlin/Enum { // io.github.linreal.cascade.editor.markdown/MarkdownCodecLimitKind|null[0] enum entry BlockNesting // io.github.linreal.cascade.editor.markdown/MarkdownCodecLimitKind.BlockNesting|null[0] enum entry Blocks // io.github.linreal.cascade.editor.markdown/MarkdownCodecLimitKind.Blocks|null[0] @@ -186,6 +190,10 @@ abstract fun interface io.github.linreal.cascade.editor.htmlserialization/TagDec abstract fun decode(io.github.linreal.cascade.editor.htmlserialization/TagDecodeContext, kotlin.collections/Map, kotlin.collections/List): io.github.linreal.cascade.editor.htmlserialization/TagDecodeResult // io.github.linreal.cascade.editor.htmlserialization/TagDecoder.decode|decode(io.github.linreal.cascade.editor.htmlserialization.TagDecodeContext;kotlin.collections.Map;kotlin.collections.List){}[0] } +abstract interface <#A: io.github.linreal.cascade.editor.core/BlockType> io.github.linreal.cascade.editor.registry/BlockPreviewRenderer { // io.github.linreal.cascade.editor.registry/BlockPreviewRenderer|null[0] + abstract fun RenderPreview(io.github.linreal.cascade.editor.core/Block, androidx.compose.ui/Modifier, io.github.linreal.cascade.editor.registry/BlockPreviewScope, androidx.compose.runtime/Composer?, kotlin/Int) // io.github.linreal.cascade.editor.registry/BlockPreviewRenderer.RenderPreview|RenderPreview(io.github.linreal.cascade.editor.core.Block;androidx.compose.ui.Modifier;io.github.linreal.cascade.editor.registry.BlockPreviewScope;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +} + abstract interface <#A: io.github.linreal.cascade.editor.core/BlockType> io.github.linreal.cascade.editor.registry/BlockRenderer { // io.github.linreal.cascade.editor.registry/BlockRenderer|null[0] open val handlesSelectionVisual // io.github.linreal.cascade.editor.registry/BlockRenderer.handlesSelectionVisual|{}handlesSelectionVisual[0] open fun (): kotlin/Boolean // io.github.linreal.cascade.editor.registry/BlockRenderer.handlesSelectionVisual.|(){}[0] @@ -229,6 +237,18 @@ abstract interface io.github.linreal.cascade.editor.registry/BlockCallbacks { // abstract fun onSlashCommand(io.github.linreal.cascade.editor.core/BlockId, io.github.linreal.cascade.editor.state/SlashQueryRange, kotlin/String = ...) // io.github.linreal.cascade.editor.registry/BlockCallbacks.onSlashCommand|onSlashCommand(io.github.linreal.cascade.editor.core.BlockId;io.github.linreal.cascade.editor.state.SlashQueryRange;kotlin.String){}[0] } +abstract interface io.github.linreal.cascade.editor.registry/BlockPreviewScope { // io.github.linreal.cascade.editor.registry/BlockPreviewScope|null[0] + abstract val blocks // io.github.linreal.cascade.editor.registry/BlockPreviewScope.blocks|{}blocks[0] + abstract fun (): kotlin.collections/List // io.github.linreal.cascade.editor.registry/BlockPreviewScope.blocks.|(){}[0] + abstract val canOpenLinks // io.github.linreal.cascade.editor.registry/BlockPreviewScope.canOpenLinks|{}canOpenLinks[0] + abstract fun (): kotlin/Boolean // io.github.linreal.cascade.editor.registry/BlockPreviewScope.canOpenLinks.|(){}[0] + abstract val config // io.github.linreal.cascade.editor.registry/BlockPreviewScope.config|{}config[0] + abstract fun (): io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig // io.github.linreal.cascade.editor.registry/BlockPreviewScope.config.|(){}[0] + + abstract fun getBlock(io.github.linreal.cascade.editor.core/BlockId): io.github.linreal.cascade.editor.core/Block? // io.github.linreal.cascade.editor.registry/BlockPreviewScope.getBlock|getBlock(io.github.linreal.cascade.editor.core.BlockId){}[0] + abstract fun openLink(kotlin/String) // io.github.linreal.cascade.editor.registry/BlockPreviewScope.openLink|openLink(kotlin.String){}[0] +} + abstract interface io.github.linreal.cascade.editor.registry/BlockRenderScope { // io.github.linreal.cascade.editor.registry/BlockRenderScope|null[0] abstract val canDragBlocks // io.github.linreal.cascade.editor.registry/BlockRenderScope.canDragBlocks|{}canDragBlocks[0] abstract fun (): kotlin/Boolean // io.github.linreal.cascade.editor.registry/BlockRenderScope.canDragBlocks.|(){}[0] @@ -2169,15 +2189,19 @@ final class io.github.linreal.cascade.editor.registry/BlockRegistry { // io.gith constructor () // io.github.linreal.cascade.editor.registry/BlockRegistry.|(){}[0] final fun <#A1: io.github.linreal.cascade.editor.core/BlockType> register(io.github.linreal.cascade.editor.registry/BlockDescriptor, io.github.linreal.cascade.editor.registry/BlockRenderer<#A1>) // io.github.linreal.cascade.editor.registry/BlockRegistry.register|register(io.github.linreal.cascade.editor.registry.BlockDescriptor;io.github.linreal.cascade.editor.registry.BlockRenderer<0:0>){0§}[0] + final fun <#A1: io.github.linreal.cascade.editor.core/BlockType> registerPreviewRenderer(kotlin/String, io.github.linreal.cascade.editor.registry/BlockPreviewRenderer<#A1>) // io.github.linreal.cascade.editor.registry/BlockRegistry.registerPreviewRenderer|registerPreviewRenderer(kotlin.String;io.github.linreal.cascade.editor.registry.BlockPreviewRenderer<0:0>){0§}[0] final fun <#A1: io.github.linreal.cascade.editor.core/BlockType> registerRenderer(kotlin/String, io.github.linreal.cascade.editor.registry/BlockRenderer<#A1>) // io.github.linreal.cascade.editor.registry/BlockRegistry.registerRenderer|registerRenderer(kotlin.String;io.github.linreal.cascade.editor.registry.BlockRenderer<0:0>){0§}[0] final fun createBlock(kotlin/String): io.github.linreal.cascade.editor.core/Block? // io.github.linreal.cascade.editor.registry/BlockRegistry.createBlock|createBlock(kotlin.String){}[0] final fun getAllDescriptors(): kotlin.collections/List // io.github.linreal.cascade.editor.registry/BlockRegistry.getAllDescriptors|getAllDescriptors(){}[0] final fun getDescriptor(kotlin/String): io.github.linreal.cascade.editor.registry/BlockDescriptor? // io.github.linreal.cascade.editor.registry/BlockRegistry.getDescriptor|getDescriptor(kotlin.String){}[0] + final fun getPreviewRenderer(io.github.linreal.cascade.editor.core/BlockType): io.github.linreal.cascade.editor.registry/BlockPreviewRenderer<*>? // io.github.linreal.cascade.editor.registry/BlockRegistry.getPreviewRenderer|getPreviewRenderer(io.github.linreal.cascade.editor.core.BlockType){}[0] + final fun getPreviewRenderer(kotlin/String): io.github.linreal.cascade.editor.registry/BlockPreviewRenderer<*>? // io.github.linreal.cascade.editor.registry/BlockRegistry.getPreviewRenderer|getPreviewRenderer(kotlin.String){}[0] final fun getRenderer(io.github.linreal.cascade.editor.core/BlockType): io.github.linreal.cascade.editor.registry/BlockRenderer<*>? // io.github.linreal.cascade.editor.registry/BlockRegistry.getRenderer|getRenderer(io.github.linreal.cascade.editor.core.BlockType){}[0] final fun getRenderer(kotlin/String): io.github.linreal.cascade.editor.registry/BlockRenderer<*>? // io.github.linreal.cascade.editor.registry/BlockRegistry.getRenderer|getRenderer(kotlin.String){}[0] final fun isRegistered(kotlin/String): kotlin/Boolean // io.github.linreal.cascade.editor.registry/BlockRegistry.isRegistered|isRegistered(kotlin.String){}[0] final fun registerDescriptor(io.github.linreal.cascade.editor.registry/BlockDescriptor) // io.github.linreal.cascade.editor.registry/BlockRegistry.registerDescriptor|registerDescriptor(io.github.linreal.cascade.editor.registry.BlockDescriptor){}[0] final fun search(kotlin/String): kotlin.collections/List // io.github.linreal.cascade.editor.registry/BlockRegistry.search|search(kotlin.String){}[0] + final fun setUnknownBlockPreviewRenderer(io.github.linreal.cascade.editor.registry/BlockPreviewRenderer<*>) // io.github.linreal.cascade.editor.registry/BlockRegistry.setUnknownBlockPreviewRenderer|setUnknownBlockPreviewRenderer(io.github.linreal.cascade.editor.registry.BlockPreviewRenderer<*>){}[0] final fun setUnknownBlockRenderer(io.github.linreal.cascade.editor.registry/BlockRenderer<*>) // io.github.linreal.cascade.editor.registry/BlockRegistry.setUnknownBlockRenderer|setUnknownBlockRenderer(io.github.linreal.cascade.editor.registry.BlockRenderer<*>){}[0] final object Companion { // io.github.linreal.cascade.editor.registry/BlockRegistry.Companion|null[0] @@ -2959,6 +2983,49 @@ final class io.github.linreal.cascade.editor.ui.renderers/TodoBlockRenderer : io final fun Render(io.github.linreal.cascade.editor.core/Block, kotlin/Boolean, kotlin/Boolean, androidx.compose.ui/Modifier, io.github.linreal.cascade.editor.registry/BlockCallbacks, androidx.compose.runtime/Composer?, kotlin/Int) // io.github.linreal.cascade.editor.ui.renderers/TodoBlockRenderer.Render|Render(io.github.linreal.cascade.editor.core.Block;kotlin.Boolean;kotlin.Boolean;androidx.compose.ui.Modifier;io.github.linreal.cascade.editor.registry.BlockCallbacks;androidx.compose.runtime.Composer?;kotlin.Int){}[0] } +final class io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig { // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig|null[0] + constructor (kotlin/Int? = ..., kotlin/Int? = ..., kotlin/Float = ..., androidx.compose.ui.text.style/TextOverflow = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., io.github.linreal.cascade.editor/CrashPolicy = ..., kotlin/Function1? = ...) // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.|(kotlin.Int?;kotlin.Int?;kotlin.Float;androidx.compose.ui.text.style.TextOverflow;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.editor.CrashPolicy;kotlin.Function1?){}[0] + + final val crashPolicy // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.crashPolicy|{}crashPolicy[0] + final fun (): io.github.linreal.cascade.editor/CrashPolicy // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.crashPolicy.|(){}[0] + final val linksEnabled // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.linksEnabled|{}linksEnabled[0] + final fun (): kotlin/Boolean // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.linksEnabled.|(){}[0] + final val maxBlocks // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.maxBlocks|{}maxBlocks[0] + final fun (): kotlin/Int? // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.maxBlocks.|(){}[0] + final val maxLinesPerTextBlock // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.maxLinesPerTextBlock|{}maxLinesPerTextBlock[0] + final fun (): kotlin/Int? // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.maxLinesPerTextBlock.|(){}[0] + final val onInternalError // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.onInternalError|{}onInternalError[0] + final fun (): kotlin/Function1? // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.onInternalError.|(){}[0] + final val textOverflow // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.textOverflow|{}textOverflow[0] + final fun (): androidx.compose.ui.text.style/TextOverflow // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.textOverflow.|(){}[0] + final val textScale // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.textScale|{}textScale[0] + final fun (): kotlin/Float // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.textScale.|(){}[0] + final val textSelectionEnabled // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.textSelectionEnabled|{}textSelectionEnabled[0] + final fun (): kotlin/Boolean // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.textSelectionEnabled.|(){}[0] + + final fun component1(): kotlin/Int? // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.component1|component1(){}[0] + final fun component2(): kotlin/Int? // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.component2|component2(){}[0] + final fun component3(): kotlin/Float // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.component3|component3(){}[0] + final fun component4(): androidx.compose.ui.text.style/TextOverflow // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.component4|component4(){}[0] + final fun component5(): kotlin/Boolean // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.component5|component5(){}[0] + final fun component6(): kotlin/Boolean // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.component6|component6(){}[0] + final fun component7(): io.github.linreal.cascade.editor/CrashPolicy // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.component7|component7(){}[0] + final fun component8(): kotlin/Function1? // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.component8|component8(){}[0] + final fun copy(kotlin/Int? = ..., kotlin/Int? = ..., kotlin/Float = ..., androidx.compose.ui.text.style/TextOverflow = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., io.github.linreal.cascade.editor/CrashPolicy = ..., kotlin/Function1? = ...): io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.copy|copy(kotlin.Int?;kotlin.Int?;kotlin.Float;androidx.compose.ui.text.style.TextOverflow;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.editor.CrashPolicy;kotlin.Function1?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.toString|toString(){}[0] + + final object Companion { // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.Companion|null[0] + final val Default // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.Companion.Default|{}Default[0] + final fun (): io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.Companion.Default.|(){}[0] + final val GridCard // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.Companion.GridCard|{}GridCard[0] + final fun (): io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.Companion.GridCard.|(){}[0] + final val Unbounded // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.Companion.Unbounded|{}Unbounded[0] + final fun (): io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig // io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig.Companion.Unbounded.|(){}[0] + } +} + final class io.github.linreal.cascade.editor.ui/CascadeEditorConfig { // io.github.linreal.cascade.editor.ui/CascadeEditorConfig|null[0] constructor (kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., io.github.linreal.cascade.editor/CrashPolicy = ..., kotlin/Function1? = ..., kotlin/Boolean = ...) // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.|(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.editor.CrashPolicy;kotlin.Function1?;kotlin.Boolean){}[0] constructor (kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, io.github.linreal.cascade.editor/CrashPolicy, kotlin/Function1?) // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.|(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.editor.CrashPolicy;kotlin.Function1?){}[0] @@ -4344,6 +4411,7 @@ final val io.github.linreal.cascade.editor.ui/LocalLinkState // io.github.linrea final fun (): androidx.compose.runtime/ProvidableCompositionLocal?> // io.github.linreal.cascade.editor.ui/LocalLinkState.|(){}[0] final val io.github.linreal.cascade.editor.ui/LocalSpanActionDispatcher // io.github.linreal.cascade.editor.ui/LocalSpanActionDispatcher|{}LocalSpanActionDispatcher[0] final fun (): androidx.compose.runtime/ProvidableCompositionLocal // io.github.linreal.cascade.editor.ui/LocalSpanActionDispatcher.|(){}[0] +final val io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_CascadeDocumentPreviewConfig$stableprop // io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_CascadeDocumentPreviewConfig$stableprop|#static{}io_github_linreal_cascade_editor_ui_CascadeDocumentPreviewConfig$stableprop[0] final val io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_CascadeEditorConfig$stableprop // io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_CascadeEditorConfig$stableprop|#static{}io_github_linreal_cascade_editor_ui_CascadeEditorConfig$stableprop[0] final val io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_By$stableprop // io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_By$stableprop|#static{}io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_By$stableprop[0] final val io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_ToItem$stableprop // io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_ToItem$stableprop|#static{}io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_ToItem$stableprop[0] @@ -4618,9 +4686,11 @@ final fun io.github.linreal.cascade.editor.ui.utils/convertVisualGapToMoveBlocks final fun io.github.linreal.cascade.editor.ui.utils/io_github_linreal_cascade_editor_ui_utils_Dividers$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.editor.ui.utils/io_github_linreal_cascade_editor_ui_utils_Dividers$stableprop_getter|io_github_linreal_cascade_editor_ui_utils_Dividers$stableprop_getter(){}[0] final fun io.github.linreal.cascade.editor.ui.utils/io_github_linreal_cascade_editor_ui_utils_Spacers$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.editor.ui.utils/io_github_linreal_cascade_editor_ui_utils_Spacers$stableprop_getter|io_github_linreal_cascade_editor_ui_utils_Spacers$stableprop_getter(){}[0] final fun io.github.linreal.cascade.editor.ui/BackspaceAwareTextField(androidx.compose.foundation.text.input/TextFieldState, androidx.compose.ui/Modifier?, androidx.compose.ui.text/TextStyle?, androidx.compose.ui.graphics/Brush?, androidx.compose.foundation.text.input/OutputTransformation?, androidx.compose.ui.focus/FocusRequester?, kotlin/Function0, kotlin/Function1, kotlin/Boolean, kotlin/Function2, kotlin/Unit>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // io.github.linreal.cascade.editor.ui/BackspaceAwareTextField|BackspaceAwareTextField(androidx.compose.foundation.text.input.TextFieldState;androidx.compose.ui.Modifier?;androidx.compose.ui.text.TextStyle?;androidx.compose.ui.graphics.Brush?;androidx.compose.foundation.text.input.OutputTransformation?;androidx.compose.ui.focus.FocusRequester?;kotlin.Function0;kotlin.Function1;kotlin.Boolean;kotlin.Function2,kotlin.Unit>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun io.github.linreal.cascade.editor.ui/CascadeDocumentPreview(kotlin.collections/List, androidx.compose.ui/Modifier?, io.github.linreal.cascade.editor.registry/BlockRegistry?, io.github.linreal.cascade.editor.theme/CascadeEditorTheme?, io.github.linreal.cascade.editor.theme/CascadeEditorStrings?, io.github.linreal.cascade.editor.theme/CascadeEditorBlockStrings?, io.github.linreal.cascade.editor.ui/CascadeDocumentPreviewConfig?, kotlin/Function1?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // io.github.linreal.cascade.editor.ui/CascadeDocumentPreview|CascadeDocumentPreview(kotlin.collections.List;androidx.compose.ui.Modifier?;io.github.linreal.cascade.editor.registry.BlockRegistry?;io.github.linreal.cascade.editor.theme.CascadeEditorTheme?;io.github.linreal.cascade.editor.theme.CascadeEditorStrings?;io.github.linreal.cascade.editor.theme.CascadeEditorBlockStrings?;io.github.linreal.cascade.editor.ui.CascadeDocumentPreviewConfig?;kotlin.Function1?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun io.github.linreal.cascade.editor.ui/CascadeEditor(io.github.linreal.cascade.editor.state/EditorStateHolder, io.github.linreal.cascade.editor.state/BlockTextStates?, io.github.linreal.cascade.editor.state/BlockSpanStates?, io.github.linreal.cascade.editor.registry/BlockRegistry?, io.github.linreal.cascade.editor.slash/SlashCommandRegistry?, io.github.linreal.cascade.editor.ui/SlashCommandSlot?, io.github.linreal.cascade.editor.theme/CascadeEditorTheme?, io.github.linreal.cascade.editor.theme/CascadeEditorStrings?, io.github.linreal.cascade.editor.theme/CascadeEditorBlockStrings?, androidx.compose.ui/Modifier?, io.github.linreal.cascade.editor.ui/ToolbarSlot?, io.github.linreal.cascade.editor.ui/LinkPopupSlot?, kotlin/Function1?, kotlin/Function1?, io.github.linreal.cascade.editor.ui/CascadeEditorConfig?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int, kotlin/Int) // io.github.linreal.cascade.editor.ui/CascadeEditor|CascadeEditor(io.github.linreal.cascade.editor.state.EditorStateHolder;io.github.linreal.cascade.editor.state.BlockTextStates?;io.github.linreal.cascade.editor.state.BlockSpanStates?;io.github.linreal.cascade.editor.registry.BlockRegistry?;io.github.linreal.cascade.editor.slash.SlashCommandRegistry?;io.github.linreal.cascade.editor.ui.SlashCommandSlot?;io.github.linreal.cascade.editor.theme.CascadeEditorTheme?;io.github.linreal.cascade.editor.theme.CascadeEditorStrings?;io.github.linreal.cascade.editor.theme.CascadeEditorBlockStrings?;androidx.compose.ui.Modifier?;io.github.linreal.cascade.editor.ui.ToolbarSlot?;io.github.linreal.cascade.editor.ui.LinkPopupSlot?;kotlin.Function1?;kotlin.Function1?;io.github.linreal.cascade.editor.ui.CascadeEditorConfig?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int;kotlin.Int){}[0] final fun io.github.linreal.cascade.editor.ui/HideKeyboardToolbarButton(kotlin/Function0, androidx.compose.ui/Modifier?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // io.github.linreal.cascade.editor.ui/HideKeyboardToolbarButton|HideKeyboardToolbarButton(kotlin.Function0;androidx.compose.ui.Modifier?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] final fun io.github.linreal.cascade.editor.ui/createEditorRegistry(): io.github.linreal.cascade.editor.registry/BlockRegistry // io.github.linreal.cascade.editor.ui/createEditorRegistry|createEditorRegistry(){}[0] +final fun io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_CascadeDocumentPreviewConfig$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_CascadeDocumentPreviewConfig$stableprop_getter|io_github_linreal_cascade_editor_ui_CascadeDocumentPreviewConfig$stableprop_getter(){}[0] final fun io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_CascadeEditorConfig$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_CascadeEditorConfig$stableprop_getter|io_github_linreal_cascade_editor_ui_CascadeEditorConfig$stableprop_getter(){}[0] final fun io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_By$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_By$stableprop_getter|io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_By$stableprop_getter(){}[0] final fun io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_ToItem$stableprop_getter(): kotlin/Int // io.github.linreal.cascade.editor.ui/io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_ToItem$stableprop_getter|io_github_linreal_cascade_editor_ui_FocusedBlockScrollTarget_ToItem$stableprop_getter(){}[0] diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/registry/BlockPreviewRenderer.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/registry/BlockPreviewRenderer.kt new file mode 100644 index 0000000..3ee17c3 --- /dev/null +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/registry/BlockPreviewRenderer.kt @@ -0,0 +1,43 @@ +package io.github.linreal.cascade.editor.registry + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockType +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi + +/** + * Stateless renderer for a block in a Cascade document preview. + * + * This is intentionally separate from [BlockRenderer]. Preview renderers + * receive no editor callbacks or mutable editor scope, and an editor renderer + * is never used automatically when a preview renderer is absent. + * + * Configure registries before sharing them across threads. Like the existing + * editor-renderer channel, registry mutation is intended for the UI thread. + * + * [CrashPolicy.ContainAndReport][io.github.linreal.cascade.editor.CrashPolicy.ContainAndReport] + * contains failures during measurement, placement, and drawing. Compose does + * not provide a reliable in-tree boundary for exceptions thrown while this + * composable is being invoked, so composition-time failures remain trusted + * extension code and should be surfaced during testing. + * + * @param T The specific [BlockType] this renderer handles. + */ +@ExperimentalCascadePreviewApi +public interface BlockPreviewRenderer { + /** + * Renders an immutable block snapshot without editor runtime machinery. + * + * @param block Immutable source block to summarize. + * @param modifier Host-provided sizing modifier. Apply it once to the + * renderer's root layout. + * @param scope Read-only document context and link capability. + */ + @Composable + public fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) +} diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/registry/BlockPreviewScope.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/registry/BlockPreviewScope.kt new file mode 100644 index 0000000..3e6821c --- /dev/null +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/registry/BlockPreviewScope.kt @@ -0,0 +1,44 @@ +package io.github.linreal.cascade.editor.registry + +import androidx.compose.runtime.Stable +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockId +import io.github.linreal.cascade.editor.ui.CascadeDocumentPreviewConfig +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi + +/** + * Read-only document context supplied to [BlockPreviewRenderer] instances. + * + * The scope deliberately exposes no editor state holder, actions, callbacks, + * focus, history, or mutation functions. [openLink] is the sole interaction + * seam and obeys the current preview configuration. + */ +@ExperimentalCascadePreviewApi +@Stable +public interface BlockPreviewScope { + /** Original ordered input snapshot, including blocks hidden by a preview limit. */ + public val blocks: List + + /** Presentation and interaction policy for this preview. */ + public val config: CascadeDocumentPreviewConfig + + /** + * Whether this preview can currently delegate link activation to its host. + * + * This is true only when links are enabled and the host supplied an opener. + * Renderers should use it before installing clickable link semantics so a + * visual-only link cannot swallow an enclosing card click. + */ + public val canOpenLinks: Boolean + + /** Returns the input block with [blockId], or `null` when it is absent. */ + public fun getBlock(blockId: BlockId): Block? + + /** + * Requests host link activation. + * + * Implementations safely do nothing when links are disabled or the host did + * not provide an opener. + */ + public fun openLink(target: String) +} diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/registry/BlockRegistry.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/registry/BlockRegistry.kt index 8857cb3..067f992 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/registry/BlockRegistry.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/registry/BlockRegistry.kt @@ -8,6 +8,7 @@ import io.github.linreal.cascade.editor.core.UnknownBlockType import io.github.linreal.cascade.editor.slash.BuiltInBlockSlashBehavior import io.github.linreal.cascade.editor.slash.BuiltInSlashCommandSpec import io.github.linreal.cascade.editor.slash.SlashCommandIconKey +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi /** * Central registry for block types, descriptors, and renderers. @@ -15,13 +16,17 @@ import io.github.linreal.cascade.editor.slash.SlashCommandIconKey * The registry serves as a single point of registration for: * - Block descriptors (metadata for slash commands) * - Block renderers (composable UI for each type) + * - Preview renderers (stateless composable UI for document previews) * * Use [createDefault] to get a registry pre-populated with built-in types. */ +@OptIn(ExperimentalCascadePreviewApi::class) public class BlockRegistry { private val descriptors = mutableMapOf() private val renderers = mutableMapOf>() + private val previewRenderers = mutableMapOf>() private var unknownBlockFallback: BlockRenderer<*>? = null + private var unknownBlockPreviewFallback: BlockPreviewRenderer<*>? = null // Snapshot-backed change counter. Bumped on every mutation so composables that // derive from the registry (e.g. the slash-command menu) can observe registrations @@ -46,6 +51,22 @@ public class BlockRegistry { revisionState.intValue++ } + /** + * Registers a stateless preview renderer independently of the editor + * renderer channel. + * + * Configure registries before sharing them across threads. Mutation remains + * a UI-thread-oriented operation. + */ + @ExperimentalCascadePreviewApi + public fun registerPreviewRenderer( + typeId: String, + renderer: BlockPreviewRenderer, + ) { + previewRenderers[typeId] = renderer + revisionState.intValue++ + } + /** * Registers both a descriptor and renderer for a block type. */ @@ -64,6 +85,14 @@ public class BlockRegistry { */ public fun getRenderer(typeId: String): BlockRenderer<*>? = renderers[typeId] + /** + * Gets the preview renderer registered for [typeId], without consulting the + * unknown/custom fallback. + */ + @ExperimentalCascadePreviewApi + public fun getPreviewRenderer(typeId: String): BlockPreviewRenderer<*>? = + previewRenderers[typeId] + /** * Gets a renderer for a [BlockType]. * @@ -78,6 +107,20 @@ public class BlockRegistry { return null } + /** + * Gets a preview renderer for [blockType]. + * + * Preview renderers are resolved only from the dedicated preview channel. + * When no exact renderer is registered, the fallback set through + * [setUnknownBlockPreviewRenderer] is returned for both unknown decoded + * types and custom types without a lightweight preview implementation. + * Existing [BlockRenderer] instances are never used as a fallback. + */ + @ExperimentalCascadePreviewApi + public fun getPreviewRenderer(blockType: BlockType): BlockPreviewRenderer<*>? { + return previewRenderers[blockType.typeId] ?: unknownBlockPreviewFallback + } + /** * Sets the fallback renderer used for [UnknownBlockType] blocks. */ @@ -86,6 +129,19 @@ public class BlockRegistry { revisionState.intValue++ } + /** + * Sets the safe fallback used when a block has no registered preview + * renderer. + * + * This channel is separate from [setUnknownBlockRenderer]; editor renderers + * are never invoked automatically by document previews. + */ + @ExperimentalCascadePreviewApi + public fun setUnknownBlockPreviewRenderer(renderer: BlockPreviewRenderer<*>) { + unknownBlockPreviewFallback = renderer + revisionState.intValue++ + } + /** * Gets all registered descriptors. */ diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/richtext/PreviewAnnotatedString.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/richtext/PreviewAnnotatedString.kt new file mode 100644 index 0000000..951b88f --- /dev/null +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/richtext/PreviewAnnotatedString.kt @@ -0,0 +1,83 @@ +package io.github.linreal.cascade.editor.richtext + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import io.github.linreal.cascade.editor.core.SpanStyle +import io.github.linreal.cascade.editor.core.TextSpan + +/** + * Builds static rich text from visible document text. + * + * The returned [AnnotatedString] contains the input text exactly as supplied: + * unlike the editor text-field adapter, this path never inserts or accounts for + * the editor's leading zero-width-space sentinel. Visual mapping, overlap + * handling, link styling, and decoration composition are shared with + * [SpanMapper]. + * + * Callers rendering from Compose should cache the result with `remember`, keyed + * by every argument. When [onOpenLink] is non-null, valid stored link spans also + * receive Compose [LinkAnnotation.Clickable] annotations that delegate to that + * callback. This path never uses Compose's implicit URI handler. + */ +internal fun buildPreviewAnnotatedString( + text: String, + spans: List, + inlineCodeBackground: Color, + highlightBackground: Color, + linkText: Color = Color.Unspecified, + baseDecoration: TextDecoration? = null, + onOpenLink: ((String) -> Unit)? = null, +): AnnotatedString { + val mapped = SpanMapper.mapRenderableSpans( + spans = spans, + inlineCodeBackground = inlineCodeBackground, + highlightBackground = highlightBackground, + linkText = linkText, + baseDecoration = baseDecoration, + ) + val linkListener = onOpenLink?.let { openLink -> + LinkInteractionListener { annotation -> + val link = annotation as? LinkAnnotation.Clickable + ?: return@LinkInteractionListener + openLink(link.tag) + } + } + + return buildAnnotatedString { + append(text) + for (span in mapped) { + val clamped = SpanMapper.clampRenderableSpan( + span = span, + visibleTextLength = text.length, + ) ?: continue + addStyle( + style = clamped.style, + start = clamped.start, + end = clamped.end, + ) + } + if (linkListener != null) { + spans.forEach { span -> + val link = span.style as? SpanStyle.Link ?: return@forEach + val target = LinkUrlPolicy.validateStoredTarget(link.url).normalizedUrl + ?: return@forEach + val start = span.start.coerceIn(0, text.length) + val end = span.end.coerceIn(start, text.length) + if (start >= end) return@forEach + + addLink( + clickable = LinkAnnotation.Clickable( + tag = target, + linkInteractionListener = linkListener, + ), + start = start, + end = end, + ) + } + } + } +} diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/richtext/SpanMapper.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/richtext/SpanMapper.kt index eb4c092..d55ba63 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/richtext/SpanMapper.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/richtext/SpanMapper.kt @@ -79,18 +79,36 @@ internal object SpanMapper { private fun TextFieldBuffer.applyMappedStyles(mapped: List) { val visibleLength = (length - sentinelOffset).coerceAtLeast(0) for (span in mapped) { - val clampedStart = span.start.coerceIn(0, visibleLength) - val clampedEnd = span.end.coerceIn(clampedStart, visibleLength) - if (clampedStart >= clampedEnd) continue - + val clamped = clampRenderableSpan(span, visibleLength) ?: continue addStyle( - spanStyle = span.style, - start = clampedStart + sentinelOffset, - end = clampedEnd + sentinelOffset, + spanStyle = clamped.style, + start = clamped.start + sentinelOffset, + end = clamped.end + sentinelOffset, ) } } + /** + * Clamps a mapped run to a visible-text boundary and drops empty ranges. + * + * Keeping this operation separate from [mapRenderableSpans] lets the editor + * cache mapped styles before a [TextFieldBuffer] is available while static + * renderers apply the exact same range semantics to static annotated text. + */ + internal fun clampRenderableSpan( + span: RenderSpan, + visibleTextLength: Int, + ): RenderSpan? { + val length = visibleTextLength.coerceAtLeast(0) + val clampedStart = span.start.coerceIn(0, length) + val clampedEnd = span.end.coerceIn(clampedStart, length) + return when { + clampedStart >= clampedEnd -> null + clampedStart == span.start && clampedEnd == span.end -> span + else -> span.copy(start = clampedStart, end = clampedEnd) + } + } + /** * Maps domain spans to renderable Compose span runs. * diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreview.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreview.kt new file mode 100644 index 0000000..4e879a4 --- /dev/null +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreview.kt @@ -0,0 +1,325 @@ +package io.github.linreal.cascade.editor.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import io.github.linreal.cascade.editor.CascadeErrorReporter +import io.github.linreal.cascade.editor.CrashPolicy +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.guarded +import io.github.linreal.cascade.editor.reportContainedFailure +import io.github.linreal.cascade.editor.registry.BlockPreviewRenderer +import io.github.linreal.cascade.editor.registry.BlockPreviewScope +import io.github.linreal.cascade.editor.registry.BlockRegistry +import io.github.linreal.cascade.editor.theme.CascadeEditorBlockStrings +import io.github.linreal.cascade.editor.theme.CascadeEditorStrings +import io.github.linreal.cascade.editor.theme.CascadeEditorTheme +import io.github.linreal.cascade.editor.theme.LocalCascadeBlockStrings +import io.github.linreal.cascade.editor.theme.LocalCascadeStrings +import io.github.linreal.cascade.editor.theme.LocalCascadeTheme +import io.github.linreal.cascade.editor.ui.renderers.LocalOrderedListPrefixStyles +import io.github.linreal.cascade.editor.ui.renderers.UnknownBlockPreviewRenderer +import io.github.linreal.cascade.editor.ui.renderers.resolveOrderedListPrefixStyles + +/** + * Renders an immutable Cascade document snapshot as bounded, static Compose UI. + * + * This component is designed for cards, feeds, search results, and other + * preview surfaces. It creates no [EditorStateHolder][io.github.linreal.cascade.editor.state.EditorStateHolder], + * text field, editor focus/IME, history, toolbar, drag, slash-command, or + * internal scroll runtime. Link annotations may still expose their normal + * accessibility focus semantics. The caller owns both the surrounding card + * and all scrolling. + * + * Preview is not a permission boundary and does not replace + * [CascadeEditor]'s read-only mode. Use the editor when the surface needs + * selectable text-field state, document scrolling, or a transition back to + * editing without remounting. + * + * The input is never normalized, renumbered, truncated into a persistence + * model, or given a trailing paragraph. [config] only controls how much of the + * immutable input is presented. + * + * Consumers rendering many previews should hoist and share a stable [registry], + * [theme], [strings], and [blockStrings]. Parse persisted JSON, HTML, or Markdown + * before entering item composition. + * + * @param blocks Original ordered document blocks. + * @param modifier Modifier for the non-scrollable preview root. + * @param registry Registry containing dedicated preview renderers. Editor + * renderers are never invoked as a fallback. + * @param theme Visual theme shared with Cascade editor rendering. + * @param strings Localized UI strings used by safe preview fallbacks. + * @param blockStrings Localized block metadata made available to built-in + * preview infrastructure. + * @param config Presentation, interaction, and containment policy. + * @param onOpenLink Optional host-controlled link activation callback. + */ +@ExperimentalCascadePreviewApi +@Composable +public fun CascadeDocumentPreview( + blocks: List, + modifier: Modifier = Modifier, + registry: BlockRegistry = remember { createEditorRegistry() }, + theme: CascadeEditorTheme = remember { CascadeEditorTheme.light() }, + strings: CascadeEditorStrings = remember { CascadeEditorStrings.default() }, + blockStrings: CascadeEditorBlockStrings = remember { + CascadeEditorBlockStrings.default() + }, + config: CascadeDocumentPreviewConfig = CascadeDocumentPreviewConfig.Default, + onOpenLink: ((String) -> Unit)? = null, +) { + val visibleBlocks = remember(blocks, config.maxBlocks) { + blocks.previewPrefix(config.maxBlocks) + } + val orderedListPrefixStyles = remember(visibleBlocks) { + resolveOrderedListPrefixStyles(visibleBlocks) + } + val hostDensity = LocalDensity.current + // Keep this at the composition boundary so future built-ins and custom + // renderers inherit the same scale without rewriting individual styles. + // Logical density stays unchanged: dp tokens are not scaled directly. + val previewDensity = remember(hostDensity, config.textScale) { + hostDensity.withPreviewTextScale(config.textScale) + } + val currentOnOpenLink = rememberUpdatedState(onOpenLink) + val hasLinkOpener = onOpenLink != null + val previewScope = remember(blocks, config, hasLinkOpener) { + DefaultBlockPreviewScope( + blocks = blocks, + config = config, + onOpenLink = if (hasLinkOpener) { + { target -> currentOnOpenLink.value?.invoke(target) } + } else { + null + }, + ) + } + val textSelectionColors = remember( + theme.colors.cursor, + theme.colors.textSelectionBackground, + ) { + TextSelectionColors( + handleColor = theme.colors.cursor, + backgroundColor = theme.colors.textSelectionBackground, + ) + } + + CompositionLocalProvider( + LocalCascadeTheme provides theme, + LocalCascadeStrings provides strings, + LocalCascadeBlockStrings provides blockStrings, + LocalTextSelectionColors provides textSelectionColors, + LocalOrderedListPrefixStyles provides orderedListPrefixStyles, + LocalDensity provides previewDensity, + ) { + val rootModifier = modifier + .fillMaxWidth() + .clipToBounds() + + if (config.textSelectionEnabled) { + SelectionContainer(modifier = rootModifier) { + PreviewBlockColumn( + blocks = visibleBlocks, + registry = registry, + scope = previewScope, + modifier = Modifier.fillMaxWidth(), + ) + } + } else { + PreviewBlockColumn( + blocks = visibleBlocks, + registry = registry, + scope = previewScope, + modifier = rootModifier, + ) + } + } +} + +private fun Density.withPreviewTextScale(textScale: Float): Density { + if (textScale == 1f) return this + + val scaledFontScale = (fontScale.toDouble() * textScale.toDouble()).toFloat() + if (!scaledFontScale.isFinite() || scaledFontScale <= 0f) return this + + return Density( + density = density, + fontScale = scaledFontScale, + ) +} + +@OptIn(ExperimentalCascadePreviewApi::class) +@Composable +private fun PreviewBlockColumn( + blocks: List, + registry: BlockRegistry, + scope: BlockPreviewScope, + modifier: Modifier, +) { + val dimensions = LocalCascadeTheme.current.dimensions + val registryRevision = registry.revision + val crashPolicy = scope.config.crashPolicy + val crashReporter = scope.config.onInternalError + + Column(modifier = modifier) { + blocks.forEachIndexed { index, block -> + // Include source index so malformed duplicate block IDs cannot + // collide at the never-crash preview boundary. + key(index, block.id.value) { + val renderer = remember(registry, registryRevision, block.type) { + registry.getPreviewRenderer(block.type) + ?: UnknownBlockPreviewRenderer + } + val failureState = remember( + renderer, + registryRevision, + block, + scope, + ) { + PreviewBlockFailureState(crashReporter) + } + Box( + modifier = Modifier + .fillMaxWidth() + .guardedPreviewBlockRender( + policy = crashPolicy, + failureState = failureState, + typeId = block.type.typeId, + ) + .padding( + horizontal = dimensions.blockHorizontalPadding, + vertical = PreviewBlockVerticalPadding, + ), + ) { + RenderPreviewBlock( + renderer = renderer, + block = block, + modifier = Modifier.fillMaxWidth(), + scope = scope, + ) + } + } + } + } +} + +@OptIn(ExperimentalCascadePreviewApi::class) +@Composable +private fun RenderPreviewBlock( + renderer: BlockPreviewRenderer<*>, + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, +) { + renderer.RenderPreview( + block = block, + modifier = modifier, + scope = scope, + ) +} + +/** + * Applies the block presentation limit without modifying block snapshots. + * + * The original list is retained when already within budget. A bounded prefix + * copies at most the configured card budget, avoiding work proportional to + * hidden document content. + */ +internal fun List.previewPrefix(maxBlocks: Int?): List { + if (maxBlocks == null || size <= maxBlocks) return this + return take(maxBlocks) +} + +/** + * Contains preview measure, placement, and draw failures. Once a mounted + * renderer fails, subsequent layout/draw passes skip it instead of repeatedly + * paying the exception cost. Compose does not permit a safe try/catch boundary + * around a composable invocation, so composition failures remain a renderer + * trust boundary just as they are in [CascadeEditor]. + */ +private fun Modifier.guardedPreviewBlockRender( + policy: CrashPolicy, + failureState: PreviewBlockFailureState, + typeId: String, +): Modifier { + if (policy == CrashPolicy.Rethrow) return this + return this + .layout { measurable, constraints -> + if (failureState.failed) { + layout(0, 0) {} + } else { + val placeable = guarded( + policy = policy, + reporter = failureState.reporter, + context = "previewBlockMeasure:$typeId", + fallback = { null }, + ) { + measurable.measure(constraints) + } + if (placeable == null) { + layout(0, 0) {} + } else { + layout(placeable.width, placeable.height) { + if (!failureState.failed) { + guarded( + policy = policy, + reporter = failureState.reporter, + context = "previewBlockPlace:$typeId", + fallback = {}, + ) { + placeable.placeRelative(0, 0) + } + } + } + } + } + } + .drawWithContent { + if (!failureState.failed) { + guarded( + policy = policy, + reporter = failureState.reporter, + context = "previewBlockDraw:$typeId", + fallback = {}, + ) { + drawContent() + } + } + } +} + +private class PreviewBlockFailureState( + private val hostReporter: CascadeErrorReporter?, +) { + var failed: Boolean = false + private set + + private var hasReported: Boolean = false + + val reporter: CascadeErrorReporter = { error -> + failed = true + if (!hasReported) { + hasReported = true + reportContainedFailure(hostReporter, error) + } + } +} + +private val PreviewBlockVerticalPadding = 4.dp diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig.kt new file mode 100644 index 0000000..8b318f7 --- /dev/null +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfig.kt @@ -0,0 +1,89 @@ +package io.github.linreal.cascade.editor.ui + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.style.TextOverflow +import io.github.linreal.cascade.editor.CascadeErrorReporter +import io.github.linreal.cascade.editor.CrashPolicy + +private const val DEFAULT_MAX_BLOCKS: Int = 4 +private const val DEFAULT_MAX_LINES_PER_TEXT_BLOCK: Int = 3 + +/** + * Presentation policy for a stateless, non-scrollable Cascade document preview. + * + * A preview renders immutable block snapshots directly. It never mutates or + * normalizes its input, appends an editor scaffold block, or provides an + * authorization boundary. Use editor read-only mode when focus, text-field + * selection, or a later transition back to editing is required. + * + * [maxBlocks] and [maxLinesPerTextBlock] are positive bounds. `null` means that + * dimension is unbounded; prefer the named [Unbounded] preset when both bounds + * should be removed. An unbounded preview still does not scroll and eagerly + * composes every supplied block, so it should not be used for grid cards or + * large documents. + * + * @param maxBlocks Maximum number of source blocks to render, or `null` for no + * block-count bound. + * @param maxLinesPerTextBlock Maximum visible lines in each text block, or + * `null` for no per-block line bound. + * @param textScale Positive multiplier applied on top of the host font scale + * to every `sp` text rendered inside the preview, including custom preview + * renderers. `dp` values are not scaled directly, but text remeasures normally, + * so wrapping and intrinsic preview size may change. + * @param textOverflow Overflow behavior applied when a text block reaches its + * line bound. + * @param textSelectionEnabled Whether static preview text may opt into Compose + * selection. Disabled by default to preserve surrounding card interactions. + * @param linksEnabled Whether preview renderers may delegate link activation to + * the host. + * @param crashPolicy Policy used by preview measurement, placement, and draw + * containment. Composition-time custom-renderer exceptions remain a trusted + * extension boundary and are not caught in-tree. + * @param onInternalError Optional host reporter for contained preview failures. + */ +@ExperimentalCascadePreviewApi +@Immutable +public data class CascadeDocumentPreviewConfig( + val maxBlocks: Int? = DEFAULT_MAX_BLOCKS, + val maxLinesPerTextBlock: Int? = DEFAULT_MAX_LINES_PER_TEXT_BLOCK, + val textScale: Float = 1f, + val textOverflow: TextOverflow = TextOverflow.Ellipsis, + val textSelectionEnabled: Boolean = false, + val linksEnabled: Boolean = true, + val crashPolicy: CrashPolicy = CrashPolicy.ContainAndReport, + val onInternalError: CascadeErrorReporter? = null, +) { + init { + require(maxBlocks == null || maxBlocks > 0) { + "maxBlocks must be greater than zero when bounded; use null or " + + "CascadeDocumentPreviewConfig.Unbounded for no block limit (was $maxBlocks)" + } + require(maxLinesPerTextBlock == null || maxLinesPerTextBlock > 0) { + "maxLinesPerTextBlock must be greater than zero when bounded; use null or " + + "CascadeDocumentPreviewConfig.Unbounded for no line limit " + + "(was $maxLinesPerTextBlock)" + } + require(textScale.isFinite() && textScale > 0f) { + "textScale must be finite and greater than zero (was $textScale)" + } + } + + public companion object { + /** Bounded 4-block × 3-line preset for grids, feeds, and search results. */ + public val GridCard: CascadeDocumentPreviewConfig = CascadeDocumentPreviewConfig() + + /** Default preview policy. Currently identical to [GridCard]. */ + public val Default: CascadeDocumentPreviewConfig = GridCard + + /** + * Explicit no-limit preset for small, static documents. + * + * This removes presentation limits only; the preview remains + * non-scrollable and eagerly composes all supplied blocks. + */ + public val Unbounded: CascadeDocumentPreviewConfig = CascadeDocumentPreviewConfig( + maxBlocks = null, + maxLinesPerTextBlock = null, + ) + } +} diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/DefaultBlockPreviewScope.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/DefaultBlockPreviewScope.kt new file mode 100644 index 0000000..f4c8a07 --- /dev/null +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/DefaultBlockPreviewScope.kt @@ -0,0 +1,44 @@ +package io.github.linreal.cascade.editor.ui + +import androidx.compose.runtime.Stable +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockId +import io.github.linreal.cascade.editor.registry.BlockPreviewScope +import io.github.linreal.cascade.editor.richtext.LinkUrlPolicy + +/** + * Default read-only scope used by [CascadeDocumentPreview]. + * + * [blocks] follows the preview API's immutable-input contract and is retained + * without a full-document copy. The lookup index is built only if a custom + * renderer asks for a block, keeping built-in previews proportional to their + * rendered prefix. Duplicate IDs resolve to the first source-order occurrence. + */ +@OptIn(ExperimentalCascadePreviewApi::class) +@Stable +internal class DefaultBlockPreviewScope( + override val blocks: List, + override val config: CascadeDocumentPreviewConfig, + private val onOpenLink: ((String) -> Unit)?, +) : BlockPreviewScope { + override val canOpenLinks: Boolean + get() = config.linksEnabled && onOpenLink != null + + private val blocksById: Map by lazy(LazyThreadSafetyMode.NONE) { + buildMap { + blocks.forEach { block -> + if (block.id !in this) { + put(block.id, block) + } + } + } + } + + override fun getBlock(blockId: BlockId): Block? = blocksById[blockId] + + override fun openLink(target: String) { + if (!canOpenLinks) return + val normalizedTarget = LinkUrlPolicy.validateStoredTarget(target).normalizedUrl ?: return + onOpenLink?.invoke(normalizedTarget) + } +} diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/EditorRegistry.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/EditorRegistry.kt index bedae0d..cf2996e 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/EditorRegistry.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/EditorRegistry.kt @@ -2,8 +2,12 @@ package io.github.linreal.cascade.editor.ui import io.github.linreal.cascade.editor.registry.BlockRegistry import io.github.linreal.cascade.editor.ui.renderers.DividerBlockRenderer +import io.github.linreal.cascade.editor.ui.renderers.PreviewDividerBlockRenderer +import io.github.linreal.cascade.editor.ui.renderers.PreviewTextBlockRenderer +import io.github.linreal.cascade.editor.ui.renderers.PreviewTodoBlockRenderer import io.github.linreal.cascade.editor.ui.renderers.TextBlockRenderer import io.github.linreal.cascade.editor.ui.renderers.TodoBlockRenderer +import io.github.linreal.cascade.editor.ui.renderers.UnknownBlockPreviewRenderer import io.github.linreal.cascade.editor.ui.renderers.UnknownBlockRenderer /** @@ -20,8 +24,10 @@ public fun createEditorRegistry(): BlockRegistry { /** * Registers built-in renderers for all standard block types. */ +@OptIn(ExperimentalCascadePreviewApi::class) public fun BlockRegistry.registerBuiltInRenderers() { val textRenderer = TextBlockRenderer() + val previewTextRenderer = PreviewTextBlockRenderer() // Register TextBlockRenderer for all text-supporting types (except todo) val textTypeIds = listOf( @@ -35,16 +41,20 @@ public fun BlockRegistry.registerBuiltInRenderers() { textTypeIds.forEach { typeId -> registerRenderer(typeId, textRenderer) + registerPreviewRenderer(typeId, previewTextRenderer) } // Register TodoBlockRenderer for todo blocks (checkbox + text) registerRenderer("todo", TodoBlockRenderer()) + registerPreviewRenderer("todo", PreviewTodoBlockRenderer()) // Register DividerBlockRenderer for divider blocks (horizontal line) registerRenderer("divider", DividerBlockRenderer()) + registerPreviewRenderer("divider", PreviewDividerBlockRenderer) // TODO: Register ImageRenderer for "image" // Fallback for UnknownBlockType (deserialized blocks with unrecognized typeId) setUnknownBlockRenderer(UnknownBlockRenderer) + setUnknownBlockPreviewRenderer(UnknownBlockPreviewRenderer) } diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/ExperimentalCascadePreviewApi.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/ExperimentalCascadePreviewApi.kt new file mode 100644 index 0000000..d8ed7c3 --- /dev/null +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/ExperimentalCascadePreviewApi.kt @@ -0,0 +1,21 @@ +package io.github.linreal.cascade.editor.ui + +/** + * Marks Cascade document-preview APIs that are still stabilizing. + * + * Preview rendering is usable, but its public contracts may change before the + * feature is declared stable. + */ +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.TYPEALIAS, +) +@RequiresOptIn( + level = RequiresOptIn.Level.WARNING, + message = "Cascade document-preview API is experimental and may change before stabilization.", +) +@MustBeDocumented +public annotation class ExperimentalCascadePreviewApi diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/BlockIndentationModifier.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/BlockIndentationModifier.kt index 159a847..3828c6a 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/BlockIndentationModifier.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/BlockIndentationModifier.kt @@ -41,6 +41,24 @@ internal fun Modifier.withBlockIndentation(block: Block): Modifier { return this.padding(start = animatedInset) } +/** + * Adds the same target leading inset as [withBlockIndentation] without animation state. + * + * Static document previews use this path so their geometry matches a settled editor block + * while remaining cheap to compose in dense grids. + */ +@Composable +internal fun Modifier.withStaticBlockIndentation(block: Block): Modifier { + if (!block.type.supportsIndentation) return this + + return padding( + start = blockIndentationInset( + block = block, + dimensions = LocalCascadeTheme.current.dimensions, + ), + ) +} + /** * Pure target inset calculation behind [withBlockIndentation]. * diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/DividerBlockRenderer.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/DividerBlockRenderer.kt index 4a1ca6b..849aec1 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/DividerBlockRenderer.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/DividerBlockRenderer.kt @@ -27,13 +27,26 @@ public class DividerBlockRenderer : BlockRenderer { modifier: Modifier, callbacks: BlockCallbacks ) { - Box( - modifier = modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Dividers.Horizontal(color = LocalCascadeTheme.current.colors.contentDivider) - } + DividerBlockVisual(modifier) + } +} + +/** + * Static divider chrome shared by editor and document-preview rendering. + * + * Keeping the visual in a standalone primitive guarantees identical line and padding + * geometry without pulling editor callbacks into preview mode. + */ +@Composable +internal fun DividerBlockVisual( + modifier: Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Dividers.Horizontal(color = LocalCascadeTheme.current.colors.contentDivider) } } diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/PreviewDividerBlockRenderer.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/PreviewDividerBlockRenderer.kt new file mode 100644 index 0000000..2814e90 --- /dev/null +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/PreviewDividerBlockRenderer.kt @@ -0,0 +1,23 @@ +package io.github.linreal.cascade.editor.ui.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockType +import io.github.linreal.cascade.editor.registry.BlockPreviewRenderer +import io.github.linreal.cascade.editor.registry.BlockPreviewScope +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi + +/** Stateless preview counterpart to [DividerBlockRenderer]. */ +@OptIn(ExperimentalCascadePreviewApi::class) +internal object PreviewDividerBlockRenderer : BlockPreviewRenderer { + + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) { + DividerBlockVisual(modifier) + } +} diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/PreviewTextBlockRenderer.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/PreviewTextBlockRenderer.kt new file mode 100644 index 0000000..583ac4c --- /dev/null +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/PreviewTextBlockRenderer.kt @@ -0,0 +1,157 @@ +package io.github.linreal.cascade.editor.ui.renderers + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDecoration +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockContent +import io.github.linreal.cascade.editor.core.BlockType +import io.github.linreal.cascade.editor.registry.BlockPreviewRenderer +import io.github.linreal.cascade.editor.registry.BlockPreviewScope +import io.github.linreal.cascade.editor.richtext.buildPreviewAnnotatedString +import io.github.linreal.cascade.editor.theme.LocalCascadeTheme +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi + +/** + * Static renderer for built-in text blocks other than todos. + * + * It deliberately owns no text field, focus, input, pointer, or animation state. Shared + * content-slot chrome keeps its settled geometry aligned with [TextBlockRenderer]. + */ +@OptIn(ExperimentalCascadePreviewApi::class) +internal class PreviewTextBlockRenderer : BlockPreviewRenderer { + + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) { + val content = block.previewTextContent() + val theme = LocalCascadeTheme.current + val targetStyle = remember(block.type, theme.typography) { + resolveTextBlockStyle(block.type, theme.typography) + }.copy(color = theme.colors.text) + + when (block.type) { + is BlockType.Code -> { + CodeBlockVisual(modifier = modifier) { + PreviewBlockText( + content = content, + textStyle = targetStyle, + modifier = Modifier.fillMaxWidth(), + scope = scope, + supportsSpans = false, + ) + } + } + + is BlockType.Quote -> { + QuoteBlockVisual(modifier = modifier) { + PreviewBlockText( + content = content, + textStyle = targetStyle, + modifier = Modifier.fillMaxWidth(), + scope = scope, + supportsSpans = block.type.supportsSpans, + ) + } + } + + is BlockType.BulletList, is BlockType.NumberedList -> { + ListPrefixRowVisual( + block = block, + textStyle = targetStyle, + modifier = modifier.withStaticBlockIndentation(block), + ) { contentModifier -> + PreviewBlockText( + content = content, + textStyle = targetStyle, + modifier = contentModifier, + scope = scope, + supportsSpans = block.type.supportsSpans, + ) + } + } + + else -> { + PreviewBlockText( + content = content, + textStyle = targetStyle, + modifier = modifier.withStaticBlockIndentation(block), + scope = scope, + supportsSpans = block.type.supportsSpans, + ) + } + } + } +} + +/** + * Resolves the static text a built-in preview renderer should present. + * + * `DocumentSchema` decodes any block whose JSON omits `content` as + * [BlockContent.Empty] — with no warning, and regardless of block type — so a + * text block or todo can legitimately reach preview rendering without + * [BlockContent.Text]. Such a block still consumes one `maxBlocks` slot, so it + * renders as an empty line instead of vanishing and silently shrinking the card. + */ +internal fun Block.previewTextContent(): BlockContent.Text = + content as? BlockContent.Text ?: EmptyPreviewTextContent + +private val EmptyPreviewTextContent = BlockContent.Text(text = "") + +/** + * Shared static text leaf for built-in preview renderers. + * + * Span conversion is cached by all visual inputs and bypassed for block types, such as + * code, that opt out of rich-text spans. + */ +@OptIn(ExperimentalCascadePreviewApi::class) +@Composable +internal fun PreviewBlockText( + content: BlockContent.Text, + textStyle: TextStyle, + modifier: Modifier, + scope: BlockPreviewScope, + supportsSpans: Boolean, + baseDecoration: TextDecoration? = null, +) { + val colors = LocalCascadeTheme.current.colors + val renderSpans = if (supportsSpans) content.spans else emptyList() + val openLink = remember(scope) { + { target: String -> scope.openLink(target) } + } + val linkHandler = openLink.takeIf { scope.canOpenLinks } + val annotatedText = remember( + content.text, + renderSpans, + colors.inlineCodeBackground, + colors.highlight, + colors.linkText, + baseDecoration, + linkHandler, + ) { + buildPreviewAnnotatedString( + text = content.text, + spans = renderSpans, + inlineCodeBackground = colors.inlineCodeBackground, + highlightBackground = colors.highlight, + linkText = colors.linkText, + baseDecoration = baseDecoration, + onOpenLink = linkHandler, + ) + } + + BasicText( + text = annotatedText, + modifier = modifier, + style = textStyle, + maxLines = scope.config.maxLinesPerTextBlock ?: Int.MAX_VALUE, + overflow = scope.config.textOverflow, + ) +} diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/PreviewTodoBlockRenderer.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/PreviewTodoBlockRenderer.kt new file mode 100644 index 0000000..da0af3b --- /dev/null +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/PreviewTodoBlockRenderer.kt @@ -0,0 +1,72 @@ +package io.github.linreal.cascade.editor.ui.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.text.style.TextDecoration +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockType +import io.github.linreal.cascade.editor.registry.BlockPreviewRenderer +import io.github.linreal.cascade.editor.registry.BlockPreviewScope +import io.github.linreal.cascade.editor.theme.LocalCascadeTheme +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi + +/** + * Static todo renderer with checked semantics but no toggle action. + */ +@OptIn(ExperimentalCascadePreviewApi::class) +internal class PreviewTodoBlockRenderer : BlockPreviewRenderer { + + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) { + val todo = block.type as? BlockType.Todo ?: return + // Gate on type only, matching TodoBlockRenderer: a todo whose content did + // not decode as text must still show its checkbox. + val content = block.previewTextContent() + val theme = LocalCascadeTheme.current + val indentationLevel = block.attributes.indentationLevel + val baseDecoration = if (todo.checked) TextDecoration.LineThrough else null + + TodoBlockRowVisual( + modifier = modifier.withStaticBlockIndentation(block), + indicator = { + TodoCheckboxIndicator( + progress = if (todo.checked) 1f else 0f, + primaryColor = theme.colors.primary, + onPrimaryColor = theme.colors.onPrimary, + borderColor = theme.colors.text.copy(alpha = 0.5f), + indentationLevel = indentationLevel, + inputModifier = Modifier.semantics { + role = Role.Checkbox + toggleableState = if (todo.checked) { + ToggleableState.On + } else { + ToggleableState.Off + } + }, + ) + }, + content = { contentModifier -> + PreviewBlockText( + content = content, + textStyle = theme.typography.body.copy( + color = theme.colors.text, + textDecoration = baseDecoration ?: TextDecoration.None, + ), + modifier = contentModifier, + scope = scope, + supportsSpans = block.type.supportsSpans, + baseDecoration = baseDecoration, + ) + }, + ) + } +} diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/TextBlockRenderer.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/TextBlockRenderer.kt index 0dd5a2c..82d39ac 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/TextBlockRenderer.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/TextBlockRenderer.kt @@ -28,7 +28,7 @@ import io.github.linreal.cascade.editor.theme.CascadeEditorTypography import io.github.linreal.cascade.editor.theme.LocalCascadeTheme import io.github.linreal.cascade.editor.ui.utils.Spacers -/** Padding between the list prefix gutter and the text field. */ +/** Padding between the list prefix gutter and the text content. */ private val ListPrefixGap = 8.dp /** Minimum width for the prefix gutter — accommodates at least 2-digit numbers like `12.` */ @@ -71,7 +71,7 @@ public class TextBlockRenderer : BlockRenderer { ) { val theme = LocalCascadeTheme.current val targetStyle = remember(block.type.typeId, theme.typography) { - getTextStyleForType(block.type, theme.typography) + resolveTextBlockStyle(block.type, theme.typography) } // Code uses a static monospace style — short-circuit before constructing the @@ -125,23 +125,33 @@ public class TextBlockRenderer : BlockRenderer { } } - private fun getTextStyleForType(type: BlockType, typography: CascadeEditorTypography): TextStyle { - return when (type) { - is BlockType.Heading -> when (type.level) { - 1 -> typography.heading1 - 2 -> typography.heading2 - 3 -> typography.heading3 - 4 -> typography.heading4 - 5 -> typography.heading5 - else -> typography.heading6 - } +} + +/** + * Resolves the static target typography shared by editor and preview renderers. + * + * The editor may animate toward this style during block conversion; preview rendering + * deliberately consumes the target directly so card grids do not create animation state. + */ +internal fun resolveTextBlockStyle( + type: BlockType, + typography: CascadeEditorTypography, +): TextStyle { + return when (type) { + is BlockType.Heading -> when (type.level) { + 1 -> typography.heading1 + 2 -> typography.heading2 + 3 -> typography.heading3 + 4 -> typography.heading4 + 5 -> typography.heading5 + else -> typography.heading6 + } - is BlockType.Quote -> typography.body.copy(fontStyle = FontStyle.Italic) + is BlockType.Quote -> typography.body.copy(fontStyle = FontStyle.Italic) - is BlockType.Code -> typography.code + is BlockType.Code -> typography.code - else -> typography.body - } + else -> typography.body } } @@ -152,6 +162,30 @@ private fun QuoteBlock( textStyle: TextStyle, modifier: Modifier, callbacks: BlockCallbacks, +) { + QuoteBlockVisual( + modifier = modifier, + ) { + TextBlockField( + block = block, + isFocused = isFocused, + textStyle = textStyle, + modifier = Modifier.fillMaxWidth(), + callbacks = callbacks, + ) + } +} + +/** + * Quote chrome shared by the editable renderer and the static preview renderer. + * + * [content] owns only the text primitive; this wrapper contains no focus, input, pointer, + * or animation behavior. + */ +@Composable +internal fun QuoteBlockVisual( + modifier: Modifier, + content: @Composable () -> Unit, ) { val colors = LocalCascadeTheme.current.colors val borderColor = colors.quoteBorder @@ -173,13 +207,7 @@ private fun QuoteBlock( bottom = QuoteContentPaddingVertical, ), ) { - TextBlockField( - block = block, - isFocused = isFocused, - textStyle = textStyle, - modifier = Modifier.fillMaxWidth(), - callbacks = callbacks, - ) + content() } } @@ -204,6 +232,30 @@ private fun CodeBlock( textStyle: TextStyle, modifier: Modifier, callbacks: BlockCallbacks, +) { + CodeBlockVisual( + modifier = modifier, + ) { + TextBlockField( + block = block, + isFocused = isFocused, + textStyle = textStyle, + modifier = Modifier.fillMaxWidth(), + callbacks = callbacks, + ) + } +} + +/** + * Code-block surface shared by editor and preview paths. + * + * The caller supplies either [TextBlockField] or a static text primitive. Keeping that + * boundary explicit prevents preview mode from inheriting editor runtime state. + */ +@Composable +internal fun CodeBlockVisual( + modifier: Modifier, + content: @Composable () -> Unit, ) { val backgroundColor = LocalCascadeTheme.current.colors.codeBlockBackground @@ -221,23 +273,45 @@ private fun CodeBlock( vertical = CodeBlockPaddingVertical, ), ) { + content() + } +} + +@Composable +private fun ListPrefixRow( + block: Block, + isFocused: Boolean, + textStyle: TextStyle, + modifier: Modifier, + callbacks: BlockCallbacks, +) { + ListPrefixRowVisual( + block = block, + textStyle = textStyle, + modifier = modifier, + ) { contentModifier -> TextBlockField( block = block, isFocused = isFocused, textStyle = textStyle, - modifier = Modifier.fillMaxWidth(), + modifier = contentModifier, callbacks = callbacks, ) } } +/** + * List marker gutter shared by editable and static text content. + * + * Marker formatting and baseline geometry live here so numbered-list ancestry styles + * cannot drift between editor and preview rendering. + */ @Composable -private fun ListPrefixRow( +internal fun ListPrefixRowVisual( block: Block, - isFocused: Boolean, textStyle: TextStyle, modifier: Modifier, - callbacks: BlockCallbacks, + content: @Composable (Modifier) -> Unit, ) { val orderedListPrefixStyles = LocalOrderedListPrefixStyles.current val prefixText = when (val type = block.type) { @@ -246,7 +320,7 @@ private fun ListPrefixRow( number = type.number, style = orderedListPrefixStyles.styleFor(block.id), ) - else -> return // should not happen + else -> return } Row( @@ -260,12 +334,6 @@ private fun ListPrefixRow( .alignByBaseline(), ) Spacers.Horizontal(ListPrefixGap) - TextBlockField( - block = block, - isFocused = isFocused, - textStyle = textStyle, - modifier = Modifier.weight(1f).alignByBaseline(), - callbacks = callbacks, - ) + content(Modifier.weight(1f).alignByBaseline()) } } diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/TodoBlockRenderer.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/TodoBlockRenderer.kt index 196f288..3fa13bf 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/TodoBlockRenderer.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/TodoBlockRenderer.kt @@ -62,7 +62,6 @@ public class TodoBlockRenderer : BlockRenderer { val currentCallbacks = rememberUpdatedState(callbacks) val currentPolicy = rememberUpdatedState(interactionPolicy) val indentationLevel = block.attributes.indentationLevel - val useRoundCheckbox = indentationLevel % 2 != 0 val onCheckedChange = remember(block.id, callbacks, interactionPolicy) { createTodoCheckedChangeAction( blockId = block.id, @@ -71,33 +70,36 @@ public class TodoBlockRenderer : BlockRenderer { ) } - Row( + TodoBlockRowVisual( modifier = modifier.withBlockIndentation(block), - verticalAlignment = Alignment.Top, - ) { - TodoCheckbox( - checked = todoType.checked, - primaryColor = theme.colors.primary, - onPrimaryColor = theme.colors.onPrimary, - borderColor = theme.colors.text.copy(alpha = 0.5f), - checkboxSize = if (indentationLevel == 0) CheckboxSize else IndentedCheckboxSize, - corner = if (useRoundCheckbox) RoundCheckboxCorner else CheckboxCorner, - stroke = if (useRoundCheckbox) RoundCheckboxStroke else CheckboxStroke, - enabled = interactionPolicy.canEditBlockControls, - onCheckedChange = onCheckedChange, - ) - Spacers.Horizontal(12.dp) - TextBlockField( - block = block, - isFocused = isFocused, - textStyle = theme.typography.body.copy( - color = theme.colors.text, - textDecoration = if (todoType.checked) TextDecoration.LineThrough else TextDecoration.None - ), - modifier = Modifier.weight(1f), - callbacks = callbacks, - ) - } + indicator = { + TodoCheckbox( + checked = todoType.checked, + primaryColor = theme.colors.primary, + onPrimaryColor = theme.colors.onPrimary, + borderColor = theme.colors.text.copy(alpha = 0.5f), + indentationLevel = indentationLevel, + enabled = interactionPolicy.canEditBlockControls, + onCheckedChange = onCheckedChange, + ) + }, + content = { contentModifier -> + TextBlockField( + block = block, + isFocused = isFocused, + textStyle = theme.typography.body.copy( + color = theme.colors.text, + textDecoration = if (todoType.checked) { + TextDecoration.LineThrough + } else { + TextDecoration.None + }, + ), + modifier = contentModifier, + callbacks = callbacks, + ) + }, + ) } } @@ -109,6 +111,28 @@ private val IndentedCheckboxSize = 18.dp private val RoundCheckboxCorner = 99.dp private val RoundCheckboxStroke = 2.dp +internal data class TodoIndicatorGeometry( + val size: Dp, + val corner: Dp, + val stroke: Dp, +) + +/** + * Resolves the todo indicator geometry shared by editor and preview paths. + * + * Odd nested lanes use the compact round marker; even nested lanes keep the compact + * square marker. The root lane retains the original larger square checkbox. + */ +internal fun resolveTodoIndicatorGeometry(indentationLevel: Int): TodoIndicatorGeometry { + val isRoot = indentationLevel == 0 + val useRoundCheckbox = indentationLevel % 2 != 0 + return TodoIndicatorGeometry( + size = if (isRoot) CheckboxSize else IndentedCheckboxSize, + corner = if (useRoundCheckbox) RoundCheckboxCorner else CheckboxCorner, + stroke = if (useRoundCheckbox) RoundCheckboxStroke else CheckboxStroke, + ) +} + /** * Creates a todo checkbox handler that resolves policy and callbacks at invoke * time. Compose may keep an old input lambda alive briefly across recomposition, @@ -131,13 +155,10 @@ internal fun createTodoCheckedChangeAction( private fun TodoCheckbox( checked: Boolean, onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, primaryColor: Color, onPrimaryColor: Color, borderColor: Color, - checkboxSize: Dp, - corner: Dp, - stroke: Dp, + indentationLevel: Int, enabled: Boolean, ) { val progress by animateFloatAsState( @@ -146,21 +167,47 @@ private fun TodoCheckbox( label = "CheckboxProgress" ) - val shape = RoundedCornerShape(corner) + TodoCheckboxIndicator( + progress = progress, + primaryColor = primaryColor, + onPrimaryColor = onPrimaryColor, + borderColor = borderColor, + indentationLevel = indentationLevel, + inputModifier = Modifier.toggleable( + value = checked, + enabled = enabled, + role = Role.Checkbox, + onValueChange = onCheckedChange, + ), + ) +} + +/** + * Draws the todo indicator without imposing interaction or animation behavior. + * + * Editor rendering supplies an animated [progress] and a toggle modifier. Preview + * rendering supplies a settled 0/1 progress and read-only checked semantics. + */ +@Composable +internal fun TodoCheckboxIndicator( + progress: Float, + primaryColor: Color, + onPrimaryColor: Color, + borderColor: Color, + indentationLevel: Int, + inputModifier: Modifier = Modifier, +) { + val geometry = resolveTodoIndicatorGeometry(indentationLevel) + val shape = RoundedCornerShape(geometry.corner) Box( - modifier = modifier - .size(checkboxSize) + modifier = Modifier + .size(geometry.size) .clip(shape) - .toggleable( - value = checked, - enabled = enabled, - role = Role.Checkbox, - onValueChange = onCheckedChange - ) + .then(inputModifier) .drawWithCache { - val strokeWidthPx = stroke.toPx() - val cornerRadiusPx = corner.toPx() + val strokeWidthPx = geometry.stroke.toPx() + val cornerRadiusPx = geometry.corner.toPx() val canvasSize = size.minDimension val inset = strokeWidthPx / 2f @@ -220,3 +267,25 @@ private fun TodoCheckbox( contentAlignment = Alignment.Center ) {} } + +/** + * Todo row geometry shared by editable and static renderers. + * + * The supplied slots decide whether the indicator is interactive and whether the text + * is an editor field or [BasicText][androidx.compose.foundation.text.BasicText]. + */ +@Composable +internal fun TodoBlockRowVisual( + modifier: Modifier, + indicator: @Composable () -> Unit, + content: @Composable (Modifier) -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.Top, + ) { + indicator() + Spacers.Horizontal(12.dp) + content(Modifier.weight(1f)) + } +} diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/UnknownBlockPreviewRenderer.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/UnknownBlockPreviewRenderer.kt new file mode 100644 index 0000000..bddabb5 --- /dev/null +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/UnknownBlockPreviewRenderer.kt @@ -0,0 +1,47 @@ +package io.github.linreal.cascade.editor.ui.renderers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockContent +import io.github.linreal.cascade.editor.core.BlockType +import io.github.linreal.cascade.editor.registry.BlockPreviewRenderer +import io.github.linreal.cascade.editor.registry.BlockPreviewScope +import io.github.linreal.cascade.editor.theme.LocalCascadeStrings +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi + +/** + * Safe fallback for unknown and custom blocks without a dedicated preview renderer. + * + * Plain text is preserved as a small excerpt. Opaque custom payloads are never inspected + * and use the localized unsupported-block label instead. + */ +@OptIn(ExperimentalCascadePreviewApi::class) +internal object UnknownBlockPreviewRenderer : BlockPreviewRenderer { + + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) { + val strings = LocalCascadeStrings.current + val text = (block.content as? BlockContent.Text) + ?.text + ?.takeIf(String::isNotBlank) + ?: strings.unsupportedBlock(block.type.typeId) + val configuredMaxLines = scope.config.maxLinesPerTextBlock ?: UnknownPreviewMaxLines + + UnknownBlockVisual( + text = text, + modifier = modifier, + maxLines = minOf(configuredMaxLines, UnknownPreviewMaxLines), + // The generic fallback is always paint-bounded, including when a caller + // explicitly chooses TextOverflow.Visible for known text blocks. + overflow = TextOverflow.Ellipsis, + ) + } +} + +private const val UnknownPreviewMaxLines = 3 diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/UnknownBlockRenderer.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/UnknownBlockRenderer.kt index 3259152..2fb91bd 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/UnknownBlockRenderer.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/renderers/UnknownBlockRenderer.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.github.linreal.cascade.editor.core.Block @@ -35,24 +36,45 @@ internal object UnknownBlockRenderer : BlockRenderer { modifier: Modifier, callbacks: BlockCallbacks, ) { - val colors = LocalCascadeTheme.current.colors val strings = LocalCascadeStrings.current - Box( - modifier = modifier - .fillMaxWidth() - .background( - color = colors.unknownBlockBackground, - shape = RoundedCornerShape(4.dp), - ) - .padding(12.dp), - ) { - BasicText( - text = strings.unsupportedBlock(block.type.typeId), - style = TextStyle( - color = colors.unknownBlockText, - fontSize = 14.sp, - ), + UnknownBlockVisual( + text = strings.unsupportedBlock(block.type.typeId), + modifier = modifier, + ) + } +} + +/** + * Lightweight unsupported-block surface shared by editor and preview fallbacks. + * + * Preview mode may supply a safe text excerpt and a bounded line count; editor mode + * retains the localized unsupported-type label and unbounded default. + */ +@Composable +internal fun UnknownBlockVisual( + text: String, + modifier: Modifier, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, +) { + val colors = LocalCascadeTheme.current.colors + Box( + modifier = modifier + .fillMaxWidth() + .background( + color = colors.unknownBlockBackground, + shape = RoundedCornerShape(4.dp), ) - } + .padding(12.dp), + ) { + BasicText( + text = text, + style = TextStyle( + color = colors.unknownBlockText, + fontSize = 14.sp, + ), + maxLines = maxLines, + overflow = overflow, + ) } } diff --git a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/BlockPreviewRegistryTest.kt b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/BlockPreviewRegistryTest.kt new file mode 100644 index 0000000..19dbb21 --- /dev/null +++ b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/BlockPreviewRegistryTest.kt @@ -0,0 +1,166 @@ +package io.github.linreal.cascade.editor + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockContent +import io.github.linreal.cascade.editor.core.BlockId +import io.github.linreal.cascade.editor.core.BlockType +import io.github.linreal.cascade.editor.core.CustomBlockType +import io.github.linreal.cascade.editor.core.UnknownBlockType +import io.github.linreal.cascade.editor.registry.BlockCallbacks +import io.github.linreal.cascade.editor.registry.BlockPreviewRenderer +import io.github.linreal.cascade.editor.registry.BlockPreviewScope +import io.github.linreal.cascade.editor.registry.BlockRegistry +import io.github.linreal.cascade.editor.registry.BlockRenderer +import io.github.linreal.cascade.editor.ui.CascadeDocumentPreviewConfig +import io.github.linreal.cascade.editor.ui.DefaultBlockPreviewScope +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@OptIn(ExperimentalCascadePreviewApi::class) +class BlockPreviewRegistryTest { + @Test + fun `empty registry has neither an exact preview renderer nor fallback`() { + val registry = BlockRegistry.create() + + assertNull(registry.getPreviewRenderer(TestBlockType)) + assertNull(registry.getPreviewRenderer(TestBlockType.typeId)) + } + + @Test + fun `matching preview renderer is returned by identity`() { + val registry = BlockRegistry.create() + val renderer = TestPreviewRenderer() + + registry.registerPreviewRenderer(TestBlockType.typeId, renderer) + + assertSame(renderer, registry.getPreviewRenderer(TestBlockType.typeId)) + assertSame(renderer, registry.getPreviewRenderer(TestBlockType)) + } + + @Test + fun `editor renderer never crosses into the preview channel`() { + val registry = BlockRegistry.create() + val editorRenderer = TestEditorRenderer() + registry.registerRenderer(TestBlockType.typeId, editorRenderer) + + assertNull(registry.getPreviewRenderer(TestBlockType.typeId)) + assertNull(registry.getPreviewRenderer(TestBlockType)) + assertSame(editorRenderer, registry.getRenderer(TestBlockType.typeId)) + } + + @Test + fun `fallback handles unknown and unregistered custom preview types`() { + val registry = BlockRegistry.create() + val fallback = TestPreviewRenderer() + registry.setUnknownBlockPreviewRenderer(fallback) + + val unknown = UnknownBlockType( + typeId = "future:block", + rawTypeJson = """{"type":"future:block"}""", + ) + + assertSame(fallback, registry.getPreviewRenderer(unknown)) + assertSame(fallback, registry.getPreviewRenderer(TestBlockType)) + assertNull(registry.getPreviewRenderer(TestBlockType.typeId)) + } + + @Test + fun `preview registration and fallback replacement increment revision`() { + val registry = BlockRegistry.create() + val startRevision = registry.revision + + registry.registerPreviewRenderer(TestBlockType.typeId, TestPreviewRenderer()) + assertTrue(registry.revision > startRevision) + + val afterRegistration = registry.revision + registry.setUnknownBlockPreviewRenderer(TestPreviewRenderer()) + assertTrue(registry.revision > afterRegistration) + } + + @Test + fun `scope retains immutable input and lookup uses first duplicate id`() { + val first = Block( + id = BlockId("duplicate"), + type = BlockType.Paragraph, + content = BlockContent.Text("First"), + ) + val duplicate = first.copy(content = BlockContent.Text("Second")) + val immutableInput = listOf(first, duplicate) + val scope = DefaultBlockPreviewScope( + blocks = immutableInput, + config = CascadeDocumentPreviewConfig.Default, + onOpenLink = null, + ) + + assertSame(immutableInput, scope.blocks) + assertSame(first, scope.getBlock(first.id)) + assertNull(scope.getBlock(BlockId("missing"))) + } + + @Test + fun `scope opens links only when policy and host callback allow it`() { + val openedTargets = mutableListOf() + val enabledScope = DefaultBlockPreviewScope( + blocks = emptyList(), + config = CascadeDocumentPreviewConfig.Default, + onOpenLink = openedTargets::add, + ) + val disabledScope = DefaultBlockPreviewScope( + blocks = emptyList(), + config = CascadeDocumentPreviewConfig.Default.copy(linksEnabled = false), + onOpenLink = openedTargets::add, + ) + val missingOpenerScope = DefaultBlockPreviewScope( + blocks = emptyList(), + config = CascadeDocumentPreviewConfig.Default, + onOpenLink = null, + ) + + enabledScope.openLink("https://example.test/enabled") + enabledScope.openLink(" ../trimmed.md ") + enabledScope.openLink(" ") + disabledScope.openLink("https://example.test/disabled") + missingOpenerScope.openLink("https://example.test/missing") + + assertTrue(enabledScope.canOpenLinks) + assertFalse(disabledScope.canOpenLinks) + assertFalse(missingOpenerScope.canOpenLinks) + assertEquals( + listOf("https://example.test/enabled", "../trimmed.md"), + openedTargets, + ) + assertFalse(openedTargets.contains("https://example.test/disabled")) + } + + private data object TestBlockType : CustomBlockType { + override val typeId: String = "custom:test-preview" + override val displayName: String = "Test Preview" + } + + private class TestPreviewRenderer : BlockPreviewRenderer { + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) = Unit + } + + private class TestEditorRenderer : BlockRenderer { + @Composable + override fun Render( + block: Block, + isSelected: Boolean, + isFocused: Boolean, + modifier: Modifier, + callbacks: BlockCallbacks, + ) = Unit + } +} diff --git a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/BuiltInPreviewVisualTest.kt b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/BuiltInPreviewVisualTest.kt new file mode 100644 index 0000000..ebe1123 --- /dev/null +++ b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/BuiltInPreviewVisualTest.kt @@ -0,0 +1,94 @@ +package io.github.linreal.cascade.editor + +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import io.github.linreal.cascade.editor.core.BlockType +import io.github.linreal.cascade.editor.theme.CascadeEditorTypography +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi +import io.github.linreal.cascade.editor.ui.createEditorRegistry +import io.github.linreal.cascade.editor.ui.renderers.resolveTextBlockStyle +import io.github.linreal.cascade.editor.ui.renderers.resolveTodoIndicatorGeometry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame + +@OptIn(ExperimentalCascadePreviewApi::class) +class BuiltInPreviewVisualTest { + + private val typography = CascadeEditorTypography.default() + + @Test + fun `static text style resolver covers every built-in text type`() { + assertEquals(typography.body, resolveTextBlockStyle(BlockType.Paragraph, typography)) + assertEquals(typography.body, resolveTextBlockStyle(BlockType.BulletList, typography)) + assertEquals( + typography.body, + resolveTextBlockStyle(BlockType.NumberedList(number = 1), typography), + ) + assertEquals(typography.body, resolveTextBlockStyle(BlockType.Todo(), typography)) + assertEquals( + typography.body.copy(fontStyle = FontStyle.Italic), + resolveTextBlockStyle(BlockType.Quote, typography), + ) + assertEquals(typography.code, resolveTextBlockStyle(BlockType.Code, typography)) + + val expectedHeadings = listOf( + typography.heading1, + typography.heading2, + typography.heading3, + typography.heading4, + typography.heading5, + typography.heading6, + ) + expectedHeadings.forEachIndexed { index, expected -> + assertEquals( + expected, + resolveTextBlockStyle(BlockType.Heading(index + 1), typography), + ) + } + } + + @Test + fun `todo indicator geometry preserves root and nested editor shapes`() { + assertEquals( + expected = Triple(20.dp, 5.dp, 2.dp), + actual = resolveTodoIndicatorGeometry(0).run { Triple(size, corner, stroke) }, + ) + assertEquals( + expected = Triple(18.dp, 99.dp, 2.dp), + actual = resolveTodoIndicatorGeometry(1).run { Triple(size, corner, stroke) }, + ) + assertEquals( + expected = Triple(18.dp, 5.dp, 2.dp), + actual = resolveTodoIndicatorGeometry(2).run { Triple(size, corner, stroke) }, + ) + } + + @Test + fun `editor registry installs dedicated preview renderers for all built-ins`() { + val registry = createEditorRegistry() + val textTypes = listOf( + BlockType.Paragraph, + BlockType.Heading(1), + BlockType.Heading(2), + BlockType.Heading(3), + BlockType.Heading(4), + BlockType.Heading(5), + BlockType.Heading(6), + BlockType.BulletList, + BlockType.NumberedList(number = 1), + BlockType.Quote, + BlockType.Code, + ) + + val sharedTextRenderer = assertNotNull( + registry.getPreviewRenderer(BlockType.Paragraph.typeId), + ) + textTypes.forEach { type -> + assertSame(sharedTextRenderer, registry.getPreviewRenderer(type.typeId)) + } + assertNotNull(registry.getPreviewRenderer(BlockType.Todo().typeId)) + assertNotNull(registry.getPreviewRenderer(BlockType.Divider.typeId)) + } +} diff --git a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/PreviewAnnotatedStringTest.kt b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/PreviewAnnotatedStringTest.kt new file mode 100644 index 0000000..d7ee6eb --- /dev/null +++ b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/PreviewAnnotatedStringTest.kt @@ -0,0 +1,375 @@ +package io.github.linreal.cascade.editor + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle as ComposeSpanStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import io.github.linreal.cascade.editor.core.SpanStyle +import io.github.linreal.cascade.editor.core.TextSpan +import io.github.linreal.cascade.editor.richtext.SpanMapper +import io.github.linreal.cascade.editor.richtext.buildPreviewAnnotatedString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class PreviewAnnotatedStringTest { + + private val inlineCodeBackground = Color(0x14000000) + private val highlightBackground = Color(0xFFFFEB3B) + private val linkText = Color(0xFF1A73E8) + + @Test + fun `bold uses the canonical font weight`() { + val result = build( + text = "bold", + spans = listOf(TextSpan(0, 4, SpanStyle.Bold)), + ) + + val run = result.singleRun() + assertEquals(0, run.start) + assertEquals(4, run.end) + assertEquals(FontWeight.Bold, run.item.fontWeight) + } + + @Test + fun `italic uses the canonical font style`() { + val result = build( + text = "italic", + spans = listOf(TextSpan(0, 6, SpanStyle.Italic)), + ) + + assertEquals(FontStyle.Italic, result.singleRun().item.fontStyle) + } + + @Test + fun `underline uses the canonical decoration`() { + val result = build( + text = "underlined", + spans = listOf(TextSpan(0, 10, SpanStyle.Underline)), + ) + + assertEquals(TextDecoration.Underline, result.singleRun().item.textDecoration) + } + + @Test + fun `strikethrough uses the canonical decoration`() { + val result = build( + text = "struck", + spans = listOf(TextSpan(0, 6, SpanStyle.StrikeThrough)), + ) + + assertEquals(TextDecoration.LineThrough, result.singleRun().item.textDecoration) + } + + @Test + fun `inline code uses the canonical monospace font and themed background`() { + val result = build( + text = "code", + spans = listOf(TextSpan(0, 4, SpanStyle.InlineCode)), + ) + + val style = result.singleRun().item + assertEquals(FontFamily.Monospace, style.fontFamily) + assertEquals(inlineCodeBackground, style.background) + } + + @Test + fun `highlight uses the editor theme color instead of the stored color`() { + val result = build( + text = "marked", + spans = listOf(TextSpan(0, 6, SpanStyle.Highlight(0xFFFF0000L))), + ) + + assertEquals(highlightBackground, result.singleRun().item.background) + } + + @Test + fun `link uses the canonical theme color and underline`() { + val result = build( + text = "linked", + spans = listOf(TextSpan(0, 6, SpanStyle.Link("https://example.com"))), + ) + + val style = result.singleRun().item + assertEquals(linkText, style.color) + assertEquals(TextDecoration.Underline, style.textDecoration) + } + + @Test + fun `link annotation delegates the validated stored target exactly once`() { + val opened = mutableListOf() + val result = buildPreviewAnnotatedString( + text = "open guide", + spans = listOf( + TextSpan(5, 10, SpanStyle.Link(" ../guide.md ")), + ), + inlineCodeBackground = inlineCodeBackground, + highlightBackground = highlightBackground, + linkText = linkText, + onOpenLink = opened::add, + ) + + val annotation = result + .getLinkAnnotations(start = 0, end = result.length) + .single() + .item as LinkAnnotation.Clickable + annotation.linkInteractionListener?.onClick(annotation) + + assertEquals(listOf("../guide.md"), opened) + } + + @Test + fun `missing link handler keeps visual styling without clickable annotation`() { + val result = build( + text = "linked", + spans = listOf(TextSpan(0, 6, SpanStyle.Link("https://example.com"))), + ) + + assertTrue(result.getLinkAnnotations(0, result.length).isEmpty()) + assertEquals(linkText, result.singleRun().item.color) + } + + @Test + fun `link annotation clamps its range to visible text`() { + val result = buildPreviewAnnotatedString( + text = "short", + spans = listOf( + TextSpan(2, Int.MAX_VALUE, SpanStyle.Link("https://example.com")), + TextSpan(5, 20, SpanStyle.Link("https://hidden.example")), + ), + inlineCodeBackground = inlineCodeBackground, + highlightBackground = highlightBackground, + linkText = linkText, + onOpenLink = {}, + ) + + val link = result.getLinkAnnotations(0, result.length).single() + assertEquals(2, link.start) + assertEquals(5, link.end) + } + + @Test + fun `oversized and empty ranges are clamped or dropped`() { + val result = build( + text = "short", + spans = listOf( + TextSpan(2, Int.MAX_VALUE, SpanStyle.Bold), + TextSpan(5, 20, SpanStyle.Italic), + TextSpan(1, 1, SpanStyle.Underline), + ), + ) + + val run = result.singleRun() + assertEquals(2, run.start) + assertEquals(5, run.end) + assertEquals(FontWeight.Bold, run.item.fontWeight) + } + + @Test + fun `negative and reversed ranges are rejected by the document model`() { + assertFailsWith { + TextSpan(-1, 2, SpanStyle.Bold) + } + assertFailsWith { + TextSpan(2, 1, SpanStyle.Bold) + } + } + + @Test + fun `shared range adapter safely clamps malformed mapped runs`() { + val bold = ComposeSpanStyle(fontWeight = FontWeight.Bold) + val malformed = listOf( + SpanMapper.RenderSpan(-4, 2, bold), + SpanMapper.RenderSpan(4, 2, bold), + SpanMapper.RenderSpan(2, 20, bold), + SpanMapper.RenderSpan(3, 3, bold), + ) + val clamped = malformed.mapNotNull { + SpanMapper.clampRenderableSpan( + span = it, + visibleTextLength = 5, + ) + } + + assertEquals( + listOf( + SpanMapper.RenderSpan(0, 2, bold), + SpanMapper.RenderSpan(2, 5, bold), + ), + clamped, + ) + } + + @Test + fun `bold and italic overlaps remain independent compatible runs`() { + val result = build( + text = "overlap", + spans = listOf( + TextSpan(0, 5, SpanStyle.Bold), + TextSpan(2, 7, SpanStyle.Italic), + ), + ) + + assertTrue( + result.spanStyles.any { + it.start == 0 && it.end == 5 && it.item.fontWeight == FontWeight.Bold + }, + ) + assertTrue( + result.spanStyles.any { + it.start == 2 && it.end == 7 && it.item.fontStyle == FontStyle.Italic + }, + ) + } + + @Test + fun `underline and strikethrough overlap emits their combined decoration`() { + val result = build( + text = "decorated", + spans = listOf( + TextSpan(0, 5, SpanStyle.Underline), + TextSpan(3, 8, SpanStyle.StrikeThrough), + ), + ) + val combined = TextDecoration.combine( + listOf(TextDecoration.Underline, TextDecoration.LineThrough), + ) + + assertTrue( + result.spanStyles.any { + it.start == 3 && it.end == 5 && it.item.textDecoration == combined + }, + ) + } + + @Test + fun `explicit underline is clipped around a link-owned underline`() { + val result = build( + text = "linkwrap", + spans = listOf( + TextSpan(0, 8, SpanStyle.Underline), + TextSpan(2, 6, SpanStyle.Link("https://example.com")), + ), + ) + + val plainUnderlinesInLinkRange = result.spanStyles.count { + it.start < 6 && + it.end > 2 && + it.item.textDecoration == TextDecoration.Underline && + it.item.color == Color.Unspecified + } + assertEquals(0, plainUnderlinesInLinkRange) + assertTrue( + result.spanStyles.any { + it.start == 0 && + it.end == 2 && + it.item.textDecoration == TextDecoration.Underline + }, + ) + assertTrue( + result.spanStyles.any { + it.start == 2 && + it.end == 6 && + it.item.color == linkText && + it.item.textDecoration == TextDecoration.Underline + }, + ) + assertTrue( + result.spanStyles.any { + it.start == 6 && + it.end == 8 && + it.item.textDecoration == TextDecoration.Underline + }, + ) + } + + @Test + fun `base todo strikethrough is composed with run decorations`() { + val result = build( + text = "todo", + spans = listOf(TextSpan(0, 4, SpanStyle.Underline)), + baseDecoration = TextDecoration.LineThrough, + ) + val combined = TextDecoration.combine( + listOf(TextDecoration.LineThrough, TextDecoration.Underline), + ) + + assertEquals(combined, result.singleRun().item.textDecoration) + } + + @Test + fun `unsupported custom style is ignored without hiding supported styles`() { + val result = build( + text = "mixed", + spans = listOf( + TextSpan(0, 5, SpanStyle.Custom("consumer.emphasis", """{"level":2}""")), + TextSpan(1, 4, SpanStyle.Bold), + ), + ) + + val run = result.singleRun() + assertEquals(1, run.start) + assertEquals(4, run.end) + assertEquals(FontWeight.Bold, run.item.fontWeight) + } + + @Test + fun `empty inputs produce a valid empty annotated string`() { + val result = build(text = "", spans = emptyList()) + + assertEquals("", result.text) + assertTrue(result.spanStyles.isEmpty()) + } + + @Test + fun `mapping leaves the input span list and its elements unchanged`() { + val bold = TextSpan(0, 4, SpanStyle.Bold) + val oversized = TextSpan(2, 40, SpanStyle.Italic) + val spans = mutableListOf(bold, oversized) + val snapshot = spans.toList() + + build(text = "input", spans = spans) + + assertEquals(snapshot, spans) + assertSame(bold, spans[0]) + assertSame(oversized, spans[1]) + } + + @Test + fun `output contains visible text exactly and introduces no sentinel`() { + val text = "Visible text" + + val result = build( + text = text, + spans = listOf(TextSpan(0, text.length, SpanStyle.Bold)), + ) + + assertEquals(text, result.text) + assertFalse('\u200B' in result.text) + } + + private fun build( + text: String, + spans: List, + baseDecoration: TextDecoration? = null, + ): AnnotatedString = buildPreviewAnnotatedString( + text = text, + spans = spans, + inlineCodeBackground = inlineCodeBackground, + highlightBackground = highlightBackground, + linkText = linkText, + baseDecoration = baseDecoration, + ) + + private fun AnnotatedString.singleRun(): AnnotatedString.Range { + assertEquals(1, spanStyles.size) + return spanStyles.single() + } +} diff --git a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfigTest.kt b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfigTest.kt new file mode 100644 index 0000000..4dd7650 --- /dev/null +++ b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewConfigTest.kt @@ -0,0 +1,109 @@ +package io.github.linreal.cascade.editor.ui + +import androidx.compose.ui.text.style.TextOverflow +import io.github.linreal.cascade.editor.CrashPolicy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@OptIn(ExperimentalCascadePreviewApi::class) +class CascadeDocumentPreviewConfigTest { + @Test + fun `default uses the centralized bounded grid card policy`() { + val config = CascadeDocumentPreviewConfig.Default + + assertSame(CascadeDocumentPreviewConfig.GridCard, config) + assertEquals(4, config.maxBlocks) + assertEquals(3, config.maxLinesPerTextBlock) + assertEquals(1f, config.textScale) + assertEquals(TextOverflow.Ellipsis, config.textOverflow) + assertFalse(config.textSelectionEnabled) + assertTrue(config.linksEnabled) + assertEquals(CrashPolicy.ContainAndReport, config.crashPolicy) + assertNull(config.onInternalError) + } + + @Test + fun `non-positive bounded block limits fail with actionable message`() { + listOf(-1, 0).forEach { invalidLimit -> + val failure = assertFailsWith { + CascadeDocumentPreviewConfig(maxBlocks = invalidLimit) + } + + assertTrue(failure.message.orEmpty().contains("maxBlocks")) + assertTrue(failure.message.orEmpty().contains("Unbounded")) + } + } + + @Test + fun `non-positive bounded line limits fail with actionable message`() { + listOf(-1, 0).forEach { invalidLimit -> + val failure = assertFailsWith { + CascadeDocumentPreviewConfig(maxLinesPerTextBlock = invalidLimit) + } + + assertTrue(failure.message.orEmpty().contains("maxLinesPerTextBlock")) + assertTrue(failure.message.orEmpty().contains("Unbounded")) + } + } + + @Test + fun `non-positive or non-finite text scales fail with actionable message`() { + listOf( + 0f, + -1f, + Float.NaN, + Float.POSITIVE_INFINITY, + Float.NEGATIVE_INFINITY, + ).forEach { invalidScale -> + val failure = assertFailsWith { + CascadeDocumentPreviewConfig(textScale = invalidScale) + } + + assertTrue(failure.message.orEmpty().contains("textScale")) + assertTrue(failure.message.orEmpty().contains("finite")) + assertTrue(failure.message.orEmpty().contains("greater than zero")) + } + } + + @Test + fun `unbounded preset removes both limits without a numeric sentinel`() { + val config = CascadeDocumentPreviewConfig.Unbounded + + assertNull(config.maxBlocks) + assertNull(config.maxLinesPerTextBlock) + assertEquals(TextOverflow.Ellipsis, config.textOverflow) + } + + @Test + fun `copy preserves crash and interaction policy when changing limits`() { + val errors = mutableListOf() + val reporter = { error: io.github.linreal.cascade.editor.CascadeError -> + errors += error.cause + } + val original = CascadeDocumentPreviewConfig( + maxBlocks = 8, + maxLinesPerTextBlock = 5, + textScale = 0.8f, + textSelectionEnabled = true, + linksEnabled = false, + crashPolicy = CrashPolicy.Rethrow, + onInternalError = reporter, + ) + + val changed = original.copy(maxBlocks = 2) + + assertEquals(2, changed.maxBlocks) + assertEquals(5, changed.maxLinesPerTextBlock) + assertEquals(0.8f, changed.textScale) + assertTrue(changed.textSelectionEnabled) + assertFalse(changed.linksEnabled) + assertEquals(CrashPolicy.Rethrow, changed.crashPolicy) + assertSame(reporter, changed.onInternalError) + assertTrue(errors.isEmpty()) + } +} diff --git a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewLogicTest.kt b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewLogicTest.kt new file mode 100644 index 0000000..3a96205 --- /dev/null +++ b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewLogicTest.kt @@ -0,0 +1,32 @@ +package io.github.linreal.cascade.editor.ui + +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class CascadeDocumentPreviewLogicTest { + + private val blocks = listOf( + Block.paragraph("one").copy(id = BlockId("one")), + Block.paragraph("two").copy(id = BlockId("two")), + Block.paragraph("three").copy(id = BlockId("three")), + ) + + @Test + fun `bounded preview returns exactly the ordered source prefix`() { + assertEquals(blocks.take(2), blocks.previewPrefix(maxBlocks = 2)) + } + + @Test + fun `unbounded preview keeps the original immutable list`() { + assertSame(blocks, blocks.previewPrefix(maxBlocks = null)) + } + + @Test + fun `already bounded preview avoids allocating another list`() { + assertSame(blocks, blocks.previewPrefix(maxBlocks = blocks.size)) + assertSame(blocks, blocks.previewPrefix(maxBlocks = blocks.size + 1)) + } +} diff --git a/editor/src/desktopTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewStaticSemanticsTest.kt b/editor/src/desktopTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewStaticSemanticsTest.kt new file mode 100644 index 0000000..85cae06 --- /dev/null +++ b/editor/src/desktopTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewStaticSemanticsTest.kt @@ -0,0 +1,97 @@ +package io.github.linreal.cascade.editor.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertIsToggleable +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.isToggleable +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.dp +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockContent +import io.github.linreal.cascade.editor.core.BlockId +import io.github.linreal.cascade.editor.core.BlockType +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalTestApi::class, ExperimentalCascadePreviewApi::class) +class CascadeDocumentPreviewStaticSemanticsTest { + + @Test + fun `built-in preview text is static and checked todo has no toggle action`() = + runComposeUiTest { + setContent { + CascadeDocumentPreview( + blocks = listOf( + Block.paragraph("Static paragraph"), + Block.todo("Completed task", checked = true), + Block.heading(level = 2, text = "Preview heading"), + ), + config = CascadeDocumentPreviewConfig.Unbounded, + ) + } + + waitForIdle() + onNodeWithText("Static paragraph").assertExists() + onNodeWithText("Completed task").assertExists() + onNodeWithText("Preview heading").assertExists() + onAllNodes(hasSetTextAction()).assertCountEquals(0) + onNode(isToggleable()) + .assertIsToggleable() + .assertIsOn() + .assertHasNoClickAction() + } + + @Test + fun `blocks whose content did not decode as text still occupy their preview slot`() = + runComposeUiTest { + // DocumentSchema decodes a block with no "content" key as + // BlockContent.Empty for any block type, with no warning. + val emptyContent = listOf( + Block(BlockId("p"), BlockType.Paragraph, BlockContent.Empty), + Block(BlockId("t"), BlockType.Todo(checked = false), BlockContent.Empty), + ) + setContent { + Column { + CascadeDocumentPreview( + blocks = emptyContent, + modifier = Modifier.width(200.dp).testTag("empty-content"), + config = CascadeDocumentPreviewConfig.Unbounded, + ) + CascadeDocumentPreview( + blocks = emptyContent.map { block -> + block.copy(content = BlockContent.Text("")) + }, + modifier = Modifier.width(200.dp).testTag("empty-text"), + config = CascadeDocumentPreviewConfig.Unbounded, + ) + } + } + + waitForIdle() + // One checkbox per preview: a todo must keep the checkbox the editor's + // TodoBlockRenderer draws even when its content did not decode as text. + onAllNodes(isToggleable()).assertCountEquals(2) + val emptyContentHeight = onNodeWithTag("empty-content") + .fetchSemanticsNode() + .boundsInRoot + .height + val emptyTextHeight = onNodeWithTag("empty-text") + .fetchSemanticsNode() + .boundsInRoot + .height + assertEquals( + emptyTextHeight, + emptyContentHeight, + "non-text content collapsed its blocks instead of rendering empty lines", + ) + } +} diff --git a/editor/src/desktopTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewTest.kt b/editor/src/desktopTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewTest.kt new file mode 100644 index 0000000..4acc2a1 --- /dev/null +++ b/editor/src/desktopTest/kotlin/io/github/linreal/cascade/editor/ui/CascadeDocumentPreviewTest.kt @@ -0,0 +1,679 @@ +package io.github.linreal.cascade.editor.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.click +import androidx.compose.ui.test.hasNoScrollAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import io.github.linreal.cascade.editor.CascadeError +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockContent +import io.github.linreal.cascade.editor.core.BlockId +import io.github.linreal.cascade.editor.core.CustomBlockType +import io.github.linreal.cascade.editor.core.SpanStyle +import io.github.linreal.cascade.editor.core.TextSpan +import io.github.linreal.cascade.editor.core.UnknownBlockType +import io.github.linreal.cascade.editor.registry.BlockPreviewRenderer +import io.github.linreal.cascade.editor.registry.BlockPreviewScope +import kotlin.test.Test +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class, ExperimentalCascadePreviewApi::class) +class CascadeDocumentPreviewTest { + + @Test + fun `empty input and bounded ordered prefix render without a trailing block`() = + runComposeUiTest { + setContent { + CascadeDocumentPreview( + blocks = emptyList(), + config = CascadeDocumentPreviewConfig.Unbounded, + ) + CascadeDocumentPreview( + blocks = listOf( + paragraph("one", "One"), + paragraph("two", "Two"), + paragraph("three", "Three"), + ), + config = CascadeDocumentPreviewConfig.Default.copy(maxBlocks = 2), + ) + } + + waitForIdle() + onNodeWithText("One").assertExists() + onNodeWithText("Two").assertExists() + onNodeWithText("Three").assertDoesNotExist() + } + + @Test + fun `duplicate block IDs are safe and static root has no editor or scroll semantics`() = + runComposeUiTest { + val duplicateId = BlockId("duplicate") + setContent { + CascadeDocumentPreview( + blocks = listOf( + Block.paragraph("Duplicate").copy(id = duplicateId), + Block.paragraph("Duplicate").copy(id = duplicateId), + ), + config = CascadeDocumentPreviewConfig.Unbounded, + ) + } + + waitForIdle() + onAllNodesWithText("Duplicate").assertCountEquals(2) + onAllNodes(hasSetTextAction()).assertCountEquals(0) + onRoot().assert(hasNoScrollAction()) + } + + @Test + fun `registry revision replaces a visible fallback without using editor renderers`() = + runComposeUiTest { + val registry = createEditorRegistry() + val block = Block( + id = BlockId("custom"), + type = TestPreviewType, + content = BlockContent.Custom(TestPreviewType.typeId), + ) + setContent { + CascadeDocumentPreview( + blocks = listOf(block), + registry = registry, + config = CascadeDocumentPreviewConfig.Unbounded, + ) + } + + waitForIdle() + onNodeWithText("Unsupported block type: ${TestPreviewType.typeId}").assertExists() + + runOnIdle { + registry.registerPreviewRenderer(TestPreviewType.typeId, TestPreviewRenderer) + } + waitForIdle() + onNodeWithText("Custom preview").assertExists() + onNodeWithText("Unsupported block type: ${TestPreviewType.typeId}") + .assertDoesNotExist() + } + + @Test + fun `one failing custom measure is contained and later blocks still render`() = + runComposeUiTest { + val reported = mutableListOf() + var measureAttempts = 0 + val hostHeight = mutableStateOf(180.dp) + val reportError: (CascadeError) -> Unit = reported::add + val previewConfig = mutableStateOf( + CascadeDocumentPreviewConfig.Unbounded.copy( + onInternalError = reportError, + ), + ) + val registry = createEditorRegistry().apply { + registerPreviewRenderer( + TestPreviewType.typeId, + ThrowingMeasurePreviewRenderer { measureAttempts++ }, + ) + } + setContent { + Box(modifier = Modifier.height(hostHeight.value)) { + CascadeDocumentPreview( + blocks = listOf( + Block( + id = BlockId("broken"), + type = TestPreviewType, + content = BlockContent.Custom(TestPreviewType.typeId), + ), + paragraph("after", "After failure"), + ), + registry = registry, + config = previewConfig.value, + ) + } + } + + waitForIdle() + onNodeWithText("After failure").assertExists() + assertTrue(reported.any { it.context == "previewBlockMeasure:${TestPreviewType.typeId}" }) + + runOnIdle { hostHeight.value = 220.dp } + waitForIdle() + assertTrue(measureAttempts == 1, "measure attempts: $measureAttempts") + assertTrue(reported.size == 1, "reported failures: $reported") + + runOnIdle { + previewConfig.value = previewConfig.value.copy( + maxLinesPerTextBlock = 2, + ) + hostHeight.value = 260.dp + } + waitForIdle() + val recoveredHeight = onNodeWithText("Recovered preview") + .fetchSemanticsNode() + .boundsInRoot + .height + assertTrue(recoveredHeight > 0f, "config change left renderer quarantined") + assertTrue(measureAttempts == 1, "recovered renderer threw again") + assertTrue(reported.size == 1, "recovery duplicated failure reporting") + } + + @Test + fun `custom placement failure is contained before later blocks are placed`() = + runComposeUiTest { + val reported = mutableListOf() + val registry = createEditorRegistry().apply { + registerPreviewRenderer(TestPreviewType.typeId, ThrowingPlacePreviewRenderer) + } + setContent { + CascadeDocumentPreview( + blocks = listOf( + Block( + id = BlockId("broken-place"), + type = TestPreviewType, + content = BlockContent.Custom(TestPreviewType.typeId), + ), + paragraph("after-place", "After placement failure"), + ), + registry = registry, + config = CascadeDocumentPreviewConfig.Unbounded.copy( + onInternalError = reported::add, + ), + ) + } + + waitForIdle() + onNodeWithText("After placement failure").assertExists() + assertTrue( + reported.single().context == "previewBlockPlace:${TestPreviewType.typeId}", + "reported failures: $reported", + ) + } + + @Test + fun `draw and reporter failures are contained and reported once per block`() = + runComposeUiTest { + var reportAttempts = 0 + val registry = createEditorRegistry().apply { + registerPreviewRenderer(TestPreviewType.typeId, ThrowingDrawPreviewRenderer) + } + setContent { + CascadeDocumentPreview( + blocks = listOf( + Block( + id = BlockId("broken-draw"), + type = TestPreviewType, + content = BlockContent.Custom(TestPreviewType.typeId), + ), + paragraph("after-draw", "After draw failure"), + ), + registry = registry, + config = CascadeDocumentPreviewConfig.Unbounded.copy( + onInternalError = { + reportAttempts++ + error("host reporter failure") + }, + ), + ) + } + + waitForIdle() + onNodeWithText("After draw failure").assertExists() + assertTrue(reportAttempts == 1, "report attempts: $reportAttempts") + } + + @Test + fun `valid built-in link activates host once without also activating card`() = + runComposeUiTest { + val opened = mutableListOf() + var cardClicks = 0 + val linkText = "Open guide" + setContent { + Box(modifier = Modifier.clickable { cardClicks++ }) { + CascadeDocumentPreview( + blocks = listOf( + Block.paragraph( + text = linkText, + spans = listOf( + TextSpan( + start = 0, + end = linkText.length, + style = SpanStyle.Link(" ../guide.md "), + ), + ), + ), + Block.paragraph("Plain text"), + ), + config = CascadeDocumentPreviewConfig.Unbounded, + onOpenLink = opened::add, + ) + } + } + + waitForIdle() + onNodeWithText("Open guide", useUnmergedTree = true) + .performTouchInput { click(Offset(4f, center.y)) } + waitForIdle() + assertTrue(opened == listOf("../guide.md"), "opened targets: $opened") + assertTrue(cardClicks == 0, "link click also activated card") + + onNodeWithText("Plain text", useUnmergedTree = true) + .performTouchInput { click(Offset(4f, center.y)) } + waitForIdle() + assertTrue(cardClicks == 1, "plain text did not activate card") + } + + @Test + fun `missing link opener leaves styled link available to outer card click`() = + runComposeUiTest { + var cardClicks = 0 + val text = "Card-owned link" + setContent { + Box(modifier = Modifier.clickable { cardClicks++ }) { + CascadeDocumentPreview( + blocks = listOf( + Block.paragraph( + text = text, + spans = listOf( + TextSpan( + 0, + text.length, + SpanStyle.Link("https://example.test"), + ), + ), + ), + ), + config = CascadeDocumentPreviewConfig.Unbounded, + ) + } + } + + waitForIdle() + onNodeWithText(text, useUnmergedTree = true) + .performTouchInput { click(Offset(4f, center.y)) } + waitForIdle() + assertTrue(cardClicks == 1, "styled link swallowed the outer card click") + } + + @Test + fun `links disabled keep link styling static and do not expose click action`() = + runComposeUiTest { + val text = "Disabled link" + setContent { + CascadeDocumentPreview( + blocks = listOf( + Block.paragraph( + text = text, + spans = listOf( + TextSpan(0, text.length, SpanStyle.Link("https://example.test")), + ), + ), + ), + config = CascadeDocumentPreviewConfig.Unbounded.copy(linksEnabled = false), + onOpenLink = { error("disabled link must not activate") }, + ) + } + + waitForIdle() + onNodeWithText(text).assertHasNoClickAction() + } + + @Test + fun `line limits reduce measured text while selection remains non-editable`() = + runComposeUiTest { + val text = "One two three four five six seven eight nine ten eleven twelve" + setContent { + Column { + CascadeDocumentPreview( + blocks = listOf(Block.paragraph(text)), + modifier = Modifier + .width(120.dp) + .testTag("bounded-preview"), + config = CascadeDocumentPreviewConfig.Default.copy( + maxBlocks = null, + maxLinesPerTextBlock = 1, + textSelectionEnabled = true, + ), + ) + CascadeDocumentPreview( + blocks = listOf(Block.paragraph(text)), + modifier = Modifier + .width(120.dp) + .testTag("unbounded-preview"), + config = CascadeDocumentPreviewConfig.Unbounded, + ) + } + } + + waitForIdle() + val boundedHeight = onNodeWithTag("bounded-preview") + .fetchSemanticsNode() + .boundsInRoot + .height + val unboundedHeight = onNodeWithTag("unbounded-preview") + .fetchSemanticsNode() + .boundsInRoot + .height + assertTrue( + boundedHeight < unboundedHeight, + "bounded=$boundedHeight, unbounded=$unboundedHeight", + ) + onAllNodes(hasSetTextAction()).assertCountEquals(0) + } + + @Test + fun `text scale multiplies host font scale for built-in and custom preview text`() = + runComposeUiTest { + var capturedDensity: Float? = null + var capturedFontScale: Float? = null + val registry = createEditorRegistry().apply { + registerPreviewRenderer( + TestPreviewType.typeId, + DensityCapturingPreviewRenderer { _, density, fontScale -> + capturedDensity = density + capturedFontScale = fontScale + }, + ) + } + val customBlock = Block( + id = BlockId("density"), + type = TestPreviewType, + content = BlockContent.Custom(TestPreviewType.typeId), + ) + + setContent { + CompositionLocalProvider( + LocalDensity provides Density(density = 2f, fontScale = 1.5f), + ) { + Column { + CascadeDocumentPreview( + blocks = listOf(Block.heading(level = 1, text = "Full heading")), + modifier = Modifier + .width(240.dp) + .testTag("full-scale-preview"), + config = CascadeDocumentPreviewConfig.Unbounded, + ) + CascadeDocumentPreview( + blocks = listOf(Block.heading(level = 1, text = "Compact heading")), + modifier = Modifier + .width(240.dp) + .testTag("compact-scale-preview"), + registry = registry, + config = CascadeDocumentPreviewConfig.Unbounded.copy( + textScale = 0.5f, + ), + ) + CascadeDocumentPreview( + blocks = listOf(customBlock), + registry = registry, + config = CascadeDocumentPreviewConfig.Unbounded.copy( + textScale = 0.75f, + ), + ) + } + } + } + + waitForIdle() + val fullHeight = onNodeWithTag("full-scale-preview") + .fetchSemanticsNode() + .boundsInRoot + .height + val compactHeight = onNodeWithTag("compact-scale-preview") + .fetchSemanticsNode() + .boundsInRoot + .height + assertTrue( + compactHeight < fullHeight, + "compact=$compactHeight, full=$fullHeight", + ) + assertTrue(capturedDensity == 2f, "captured density: $capturedDensity") + assertTrue(capturedFontScale == 1.125f, "captured font scale: $capturedFontScale") + } + + @Test + fun `unrepresentable scaled font density falls back to the host values`() = + runComposeUiTest { + val captured = mutableMapOf>() + val registry = createEditorRegistry().apply { + registerPreviewRenderer( + TestPreviewType.typeId, + DensityCapturingPreviewRenderer { textScale, density, fontScale -> + captured[textScale] = density to fontScale + }, + ) + } + val customBlock = Block( + id = BlockId("extreme-density"), + type = TestPreviewType, + content = BlockContent.Custom(TestPreviewType.typeId), + ) + + setContent { + Column { + CompositionLocalProvider( + LocalDensity provides Density(density = 2f, fontScale = 0.5f), + ) { + CascadeDocumentPreview( + blocks = listOf(customBlock), + registry = registry, + config = CascadeDocumentPreviewConfig.Unbounded.copy( + textScale = Float.MIN_VALUE, + ), + ) + } + CompositionLocalProvider( + LocalDensity provides Density(density = 3f, fontScale = 2f), + ) { + CascadeDocumentPreview( + blocks = listOf(customBlock), + registry = registry, + config = CascadeDocumentPreviewConfig.Unbounded.copy( + textScale = Float.MAX_VALUE, + ), + ) + } + } + } + + waitForIdle() + assertTrue( + captured[Float.MIN_VALUE] == (2f to 0.5f), + "underflow density: ${captured[Float.MIN_VALUE]}", + ) + assertTrue( + captured[Float.MAX_VALUE] == (3f to 2f), + "overflow density: ${captured[Float.MAX_VALUE]}", + ) + } + + @Test + fun `unknown fallback preserves text and hides opaque custom payloads`() = + runComposeUiTest { + setContent { + CascadeDocumentPreview( + blocks = listOf( + Block( + id = BlockId("unknown-text"), + type = UnknownBlockType("future.text", """{"type":"future.text"}"""), + content = BlockContent.Text("Readable future content"), + ), + Block( + id = BlockId("unknown-opaque"), + type = UnknownBlockType( + "future.opaque", + """{"type":"future.opaque"}""", + ), + content = BlockContent.Custom( + typeId = "future.opaque", + data = mapOf("private" to "payload"), + ), + ), + ), + config = CascadeDocumentPreviewConfig.Unbounded, + ) + } + + waitForIdle() + onNodeWithText("Readable future content").assertExists() + onNodeWithText("Unsupported block type: future.opaque").assertExists() + onNodeWithText("payload").assertDoesNotExist() + } + + @Test + fun `fixed-height previews measure safely inside an outer lazy grid`() = + runComposeUiTest { + val documents = List(8) { index -> + listOf( + paragraph("$index-heading", "Note $index"), + paragraph("$index-body", "Static preview body"), + ) + } + setContent { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.size(600.dp), + ) { + items(documents) { blocks -> + CascadeDocumentPreview( + blocks = blocks, + modifier = Modifier.height(180.dp), + ) + } + } + } + + waitForIdle() + onNodeWithText("Note 0").assertExists() + } + + private data object TestPreviewType : CustomBlockType { + override val typeId: String = "test.preview" + override val displayName: String = "Test preview" + } + + private data object TestPreviewRenderer : BlockPreviewRenderer { + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) { + BasicText("Custom preview", modifier) + } + } + + private class DensityCapturingPreviewRenderer( + private val onDensity: ( + textScale: Float, + density: Float, + fontScale: Float, + ) -> Unit, + ) : BlockPreviewRenderer { + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) { + val density = LocalDensity.current + SideEffect { + onDensity(scope.config.textScale, density.density, density.fontScale) + } + BasicText("Density preview", modifier) + } + } + + private class ThrowingMeasurePreviewRenderer( + private val onMeasure: () -> Unit, + ) : + BlockPreviewRenderer { + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) { + if (scope.config.maxLinesPerTextBlock == null) { + Box( + // Deliberately ignore the supplied modifier: containment belongs + // to the host boundary, not renderer cooperation. + modifier = Modifier.layout { _, _ -> + onMeasure() + error("preview measure failure") + }, + ) + } else { + BasicText("Recovered preview", modifier) + } + } + } + + private data object ThrowingPlacePreviewRenderer : + BlockPreviewRenderer { + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) { + Layout( + content = {}, + modifier = Modifier.size(20.dp), + ) { _, constraints -> + layout( + width = constraints.minWidth, + height = constraints.minHeight, + ) { + error("preview placement failure") + } + } + } + } + + private data object ThrowingDrawPreviewRenderer : + BlockPreviewRenderer { + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) { + Box( + modifier = Modifier + .size(20.dp) + .drawBehind { error("preview draw failure") }, + ) + } + } +} + +private fun paragraph(id: String, text: String): Block = + Block.paragraph(text).copy(id = BlockId(id)) diff --git a/iosNativeSample/iosNativeSample/App/AppTheme.swift b/iosNativeSample/iosNativeSample/App/AppTheme.swift index 0d26c20..279a3f9 100644 --- a/iosNativeSample/iosNativeSample/App/AppTheme.swift +++ b/iosNativeSample/iosNativeSample/App/AppTheme.swift @@ -59,6 +59,8 @@ struct AppTheme { let tileBlocksIcon: Color let tileCommentsBackground: Color let tileCommentsIcon: Color + let tilePreviewBackground: Color + let tilePreviewIcon: Color static let violetLight = AppTheme( name: "Violet Light", @@ -92,7 +94,9 @@ struct AppTheme { tileBlocksBackground: Color(rgb: 0xF0E9FE), tileBlocksIcon: Color(rgb: 0x6C3DE8), tileCommentsBackground: Color(rgb: 0xFFEDE7), - tileCommentsIcon: Color(rgb: 0xFF6B4A) + tileCommentsIcon: Color(rgb: 0xFF6B4A), + tilePreviewBackground: Color(rgb: 0xE8F5FF), + tilePreviewIcon: Color(rgb: 0x2478B5) ) static let violetDark = AppTheme( @@ -127,7 +131,9 @@ struct AppTheme { tileBlocksBackground: Color(argb: 0x2E8B5CF6), tileBlocksIcon: Color(rgb: 0xC4B5FD), tileCommentsBackground: Color(argb: 0x29FF6B4A), - tileCommentsIcon: Color(rgb: 0xFF8A6B) + tileCommentsIcon: Color(rgb: 0xFF8A6B), + tilePreviewBackground: Color(argb: 0x2E38A3DB), + tilePreviewIcon: Color(rgb: 0x7CC7F2) ) static let forestLight = AppTheme( @@ -162,7 +168,9 @@ struct AppTheme { tileBlocksBackground: Color(rgb: 0xDCEFE7), tileBlocksIcon: Color(rgb: 0x147D64), tileCommentsBackground: Color(rgb: 0xFFF0D9), - tileCommentsIcon: Color(rgb: 0xC66A16) + tileCommentsIcon: Color(rgb: 0xC66A16), + tilePreviewBackground: Color(rgb: 0xE1F2F4), + tilePreviewIcon: Color(rgb: 0x167080) ) static let forestDark = AppTheme( @@ -197,7 +205,9 @@ struct AppTheme { tileBlocksBackground: Color(argb: 0x2938A383), tileBlocksIcon: Color(rgb: 0x8FE0C4), tileCommentsBackground: Color(argb: 0x29D99A4E), - tileCommentsIcon: Color(rgb: 0xE8B66B) + tileCommentsIcon: Color(rgb: 0xE8B66B), + tilePreviewBackground: Color(argb: 0x293AA7B5), + tilePreviewIcon: Color(rgb: 0x83D8E2) ) } diff --git a/iosNativeSample/iosNativeSample/App/LandingView.swift b/iosNativeSample/iosNativeSample/App/LandingView.swift index 31b5186..a72a403 100644 --- a/iosNativeSample/iosNativeSample/App/LandingView.swift +++ b/iosNativeSample/iosNativeSample/App/LandingView.swift @@ -29,12 +29,20 @@ struct LandingView: View { .kerning(2.2) .foregroundStyle(theme.onBackground) Spacer() - Text("3 modules") + Text("4 modules") .font(.system(size: 13, weight: .medium)) .foregroundStyle(theme.cardDescription) } Spacer().frame(height: 16) VStack(spacing: 12) { + moduleCard( + title: "Preview Grid", + description: "Persisted documents that open into the full editor", + systemImage: "square.grid.2x2", + tileBackground: theme.tilePreviewBackground, + tileIcon: theme.tilePreviewIcon, + destination: .previewGallery + ) moduleCard( title: "Comments", description: "Editor as a chat composer with rich-text bubbles", diff --git a/iosNativeSample/iosNativeSample/App/NativeSampleApp.swift b/iosNativeSample/iosNativeSample/App/NativeSampleApp.swift index 67e1936..454ed9a 100644 --- a/iosNativeSample/iosNativeSample/App/NativeSampleApp.swift +++ b/iosNativeSample/iosNativeSample/App/NativeSampleApp.swift @@ -13,6 +13,8 @@ struct NativeSampleApp: App { enum SampleDestination: Hashable { case editorDemo + case previewGallery + case previewDocument(id: String) case comments case customBlocks } @@ -21,6 +23,7 @@ struct RootView: View { @Binding var selectedTheme: SampleThemeFamily @Environment(\.colorScheme) private var colorScheme @State private var path: [SampleDestination] = [] + @StateObject private var previewDocuments = PreviewDocumentLibrary() private var isDark: Bool { colorScheme == .dark } private var theme: AppTheme { selectedTheme.appTheme(isDark: isDark) } @@ -43,6 +46,21 @@ struct RootView: View { switch destination { case .editorDemo: EditorDemoScreen(selectedTheme: $selectedTheme, initialIsDark: isDark) + case .previewGallery: + PreviewGalleryScreen( + selectedTheme: $selectedTheme, + library: previewDocuments, + onOpenDocument: { documentID in + path.append(.previewDocument(id: documentID)) + } + ) + case .previewDocument(let documentID): + PreviewDocumentEditorScreen( + documentID: documentID, + library: previewDocuments, + selectedTheme: $selectedTheme, + initialIsDark: isDark + ) case .comments: CommentsScreen(selectedTheme: $selectedTheme, initialIsDark: isDark) case .customBlocks: diff --git a/iosNativeSample/iosNativeSample/Editor/CascadeDocumentPreviewHost.swift b/iosNativeSample/iosNativeSample/Editor/CascadeDocumentPreviewHost.swift new file mode 100644 index 0000000..64c1a4f --- /dev/null +++ b/iosNativeSample/iosNativeSample/Editor/CascadeDocumentPreviewHost.swift @@ -0,0 +1,20 @@ +import SwiftUI +import UIKit +import CascadeEditor + +/// Thin SwiftUI bridge for the SDK's stateless document-preview controller. +/// +/// The owning grid cell retains the controller, so SwiftUI updates do not +/// remount its Compose tree. The host itself has no sizing or interaction +/// policy; callers provide a bounded frame and decide how taps are handled. +struct CascadeDocumentPreviewHost: UIViewControllerRepresentable { + let controller: CascadeDocumentPreviewController + + func makeUIViewController(context: Context) -> UIViewController { + controller.makeViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + // Content and theme changes flow through the preview-controller API. + } +} diff --git a/iosNativeSample/iosNativeSample/Resources/preview_code_review.json b/iosNativeSample/iosNativeSample/Resources/preview_code_review.json new file mode 100644 index 0000000..00003c7 --- /dev/null +++ b/iosNativeSample/iosNativeSample/Resources/preview_code_review.json @@ -0,0 +1,47 @@ +{ + "version": 2, + "blocks": [ + { + "id": "review-title", + "type": { "typeId": "heading_1" }, + "content": { + "kind": "text", + "version": 1, + "text": "Code review", + "spans": [] + } + }, + { + "id": "review-summary", + "type": { "typeId": "paragraph" }, + "content": { + "kind": "text", + "version": 1, + "text": "Keep the preview stateless and let the host own navigation.", + "spans": [ + { "start": 17, "end": 26, "style": { "type": "inline_code" } } + ] + } + }, + { + "id": "review-code", + "type": { "typeId": "code" }, + "content": { + "kind": "text", + "version": 1, + "text": "card.onTap {\n openEditor(document.id)\n}", + "spans": [] + } + }, + { + "id": "review-followup", + "type": { "typeId": "todo", "checked": false }, + "content": { + "kind": "text", + "version": 1, + "text": "Profile scrolling on a release build", + "spans": [] + } + } + ] +} diff --git a/iosNativeSample/iosNativeSample/Resources/preview_product_brief.json b/iosNativeSample/iosNativeSample/Resources/preview_product_brief.json new file mode 100644 index 0000000..46f14a7 --- /dev/null +++ b/iosNativeSample/iosNativeSample/Resources/preview_product_brief.json @@ -0,0 +1,62 @@ +{ + "version": 2, + "blocks": [ + { + "id": "product-title", + "type": { "typeId": "heading_1" }, + "content": { + "kind": "text", + "version": 1, + "text": "Product launch brief", + "spans": [ + { "start": 0, "end": 20, "style": { "type": "bold" } } + ] + } + }, + { + "id": "product-summary", + "type": { "typeId": "paragraph" }, + "content": { + "kind": "text", + "version": 1, + "text": "A focused release for teams that need fast, structured notes without losing editing power.", + "spans": [] + } + }, + { + "id": "product-divider", + "type": { "typeId": "divider" }, + "content": { "kind": "empty" } + }, + { + "id": "product-goals-title", + "type": { "typeId": "heading_2" }, + "content": { + "kind": "text", + "version": 1, + "text": "Launch goals", + "spans": [] + } + }, + { + "id": "product-goal-one", + "type": { "typeId": "bullet_list" }, + "content": { + "kind": "text", + "version": 1, + "text": "Make the first edit feel immediate", + "spans": [] + } + }, + { + "id": "product-goal-two", + "type": { "typeId": "todo", "checked": false }, + "content": { + "kind": "text", + "version": 1, + "text": "Collect launch feedback", + "spans": [] + } + } + ] +} diff --git a/iosNativeSample/iosNativeSample/Resources/preview_release_checklist.json b/iosNativeSample/iosNativeSample/Resources/preview_release_checklist.json new file mode 100644 index 0000000..1dff3a8 --- /dev/null +++ b/iosNativeSample/iosNativeSample/Resources/preview_release_checklist.json @@ -0,0 +1,55 @@ +{ + "version": 2, + "blocks": [ + { + "id": "release-title", + "type": { "typeId": "heading_1" }, + "content": { + "kind": "text", + "version": 1, + "text": "Release checklist", + "spans": [] + } + }, + { + "id": "release-api", + "type": { "typeId": "todo", "checked": true }, + "content": { + "kind": "text", + "version": 1, + "text": "Review the public API diff", + "spans": [] + } + }, + { + "id": "release-tests", + "type": { "typeId": "todo", "checked": true }, + "content": { + "kind": "text", + "version": 1, + "text": "Run multiplatform tests", + "spans": [] + } + }, + { + "id": "release-device", + "type": { "typeId": "todo", "checked": false }, + "content": { + "kind": "text", + "version": 1, + "text": "Smoke-test a physical device", + "spans": [] + } + }, + { + "id": "release-notes", + "type": { "typeId": "todo", "checked": false }, + "content": { + "kind": "text", + "version": 1, + "text": "Publish concise release notes", + "spans": [] + } + } + ] +} diff --git a/iosNativeSample/iosNativeSample/Resources/preview_research_notes.json b/iosNativeSample/iosNativeSample/Resources/preview_research_notes.json new file mode 100644 index 0000000..17d4c11 --- /dev/null +++ b/iosNativeSample/iosNativeSample/Resources/preview_research_notes.json @@ -0,0 +1,65 @@ +{ + "version": 2, + "blocks": [ + { + "id": "research-title", + "type": { "typeId": "heading_1" }, + "content": { + "kind": "text", + "version": 1, + "text": "Research notes", + "spans": [] + } + }, + { + "id": "research-quote", + "type": { "typeId": "quote" }, + "content": { + "kind": "text", + "version": 1, + "text": "A useful preview helps someone decide what to open.", + "spans": [] + } + }, + { + "id": "research-source", + "type": { "typeId": "paragraph" }, + "content": { + "kind": "text", + "version": 1, + "text": "Keep source material close to every decision.", + "spans": [ + { + "start": 5, + "end": 20, + "style": { + "type": "link", + "url": "https://github.com/linreal/cascade-editor" + } + } + ] + } + }, + { + "id": "research-question", + "type": { "typeId": "bullet_list" }, + "content": { + "kind": "text", + "version": 1, + "text": "How much context belongs on a card?", + "spans": [] + } + }, + { + "id": "research-answer", + "type": { "typeId": "paragraph" }, + "attributes": { "indentationLevel": 1 }, + "content": { + "kind": "text", + "version": 1, + "text": "Enough to recognize the document, never enough to replace it.", + "spans": [] + } + } + ] +} diff --git a/iosNativeSample/iosNativeSample/Resources/preview_trip_ideas.json b/iosNativeSample/iosNativeSample/Resources/preview_trip_ideas.json new file mode 100644 index 0000000..db88920 --- /dev/null +++ b/iosNativeSample/iosNativeSample/Resources/preview_trip_ideas.json @@ -0,0 +1,57 @@ +{ + "version": 2, + "blocks": [ + { + "id": "trip-title", + "type": { "typeId": "heading_1" }, + "content": { + "kind": "text", + "version": 1, + "text": "Trip ideas", + "spans": [] + } + }, + { + "id": "trip-summary", + "type": { "typeId": "paragraph" }, + "content": { + "kind": "text", + "version": 1, + "text": "Start with the coast, then leave room for detours.", + "spans": [ + { "start": 27, "end": 49, "style": { "type": "italic" } } + ] + } + }, + { + "id": "trip-place", + "type": { "typeId": "bullet_list" }, + "content": { + "kind": "text", + "version": 1, + "text": "Old harbor at sunrise", + "spans": [] + } + }, + { + "id": "trip-food", + "type": { "typeId": "bullet_list" }, + "content": { + "kind": "text", + "version": 1, + "text": "Find the smallest local bakery", + "spans": [] + } + }, + { + "id": "trip-order", + "type": { "typeId": "numbered_list", "number": 1 }, + "content": { + "kind": "text", + "version": 1, + "text": "Book the first night", + "spans": [] + } + } + ] +} diff --git a/iosNativeSample/iosNativeSample/Resources/preview_weekly_plan.json b/iosNativeSample/iosNativeSample/Resources/preview_weekly_plan.json new file mode 100644 index 0000000..40ffefb --- /dev/null +++ b/iosNativeSample/iosNativeSample/Resources/preview_weekly_plan.json @@ -0,0 +1,57 @@ +{ + "version": 2, + "blocks": [ + { + "id": "week-title", + "type": { "typeId": "heading_1" }, + "content": { + "kind": "text", + "version": 1, + "text": "This week", + "spans": [ + { "start": 0, "end": 9, "style": { "type": "bold" } } + ] + } + }, + { + "id": "week-done", + "type": { "typeId": "todo", "checked": true }, + "content": { + "kind": "text", + "version": 1, + "text": "Confirm the preview contract", + "spans": [] + } + }, + { + "id": "week-design", + "type": { "typeId": "todo", "checked": false }, + "content": { + "kind": "text", + "version": 1, + "text": "Review the grid on compact screens", + "spans": [] + } + }, + { + "id": "week-qa", + "type": { "typeId": "todo", "checked": false }, + "content": { + "kind": "text", + "version": 1, + "text": "Run accessibility checks", + "spans": [] + } + }, + { + "id": "week-report", + "type": { "typeId": "numbered_list", "number": 1 }, + "content": { + "kind": "text", + "version": 1, + "text": "Write the field report", + "spans": [] + } + } + ] +} diff --git a/iosNativeSample/iosNativeSample/Screens/Preview/PreviewCardModel.swift b/iosNativeSample/iosNativeSample/Screens/Preview/PreviewCardModel.swift new file mode 100644 index 0000000..d3462b0 --- /dev/null +++ b/iosNativeSample/iosNativeSample/Screens/Preview/PreviewCardModel.swift @@ -0,0 +1,63 @@ +import Combine +import Foundation +import CascadeEditor + +/// Owns one preview controller for one visible grid cell. +/// +/// Document and theme changes update the existing controller rather than +/// replacing its UIKit/Compose host. When SwiftUI releases the cell, this model +/// and its controller are released together. +@MainActor +final class PreviewCardModel: ObservableObject { + let controller: CascadeDocumentPreviewController + + @Published private(set) var errorMessage = "" + + private var currentJson: String + private var currentIsDark: Bool + + init(json: String, isDark: Bool) { + currentJson = json + currentIsDark = isDark + + let configuration = CascadeDocumentPreviewConfiguration( + maxBlocks: 4, + maxLinesPerTextBlock: 3, + textScale: 0.8, + textSelectionEnabled: false, + linksEnabled: false, + isDark: isDark, + crashPolicy: CascadeCrashPolicy.containAndReport + ) + controller = CascadeDocumentPreviewController(configuration: configuration) + + controller.onInternalError = { [weak self] message in + self?.errorMessage = message + } + // Grid cards own the tap target. Keeping this callback nil is a second + // line of defense alongside linksEnabled=false. + controller.onOpenLink = nil + + let result = controller.loadJson(json: json) + if !result.success { + errorMessage = "The saved preview could not be loaded." + } + } + + func update(json: String, isDark: Bool) { + if json != currentJson { + let result = controller.loadJson(json: json) + if result.success { + currentJson = json + errorMessage = "" + } else { + errorMessage = "The saved preview could not be loaded." + } + } + + if isDark != currentIsDark { + currentIsDark = isDark + controller.setDarkMode(value: isDark) + } + } +} diff --git a/iosNativeSample/iosNativeSample/Screens/Preview/PreviewDocumentEditorModel.swift b/iosNativeSample/iosNativeSample/Screens/Preview/PreviewDocumentEditorModel.swift new file mode 100644 index 0000000..ee9df09 --- /dev/null +++ b/iosNativeSample/iosNativeSample/Screens/Preview/PreviewDocumentEditorModel.swift @@ -0,0 +1,165 @@ +import Combine +import SwiftUI +import UIKit + +/// Editing-session logic for a document opened from the preview grid. +/// +/// The model loads the library snapshot into a regular editor controller, +/// debounces exports during typing, flushes on exit, and publishes every +/// successful save back to the library so the retained grid preview updates. +@MainActor +final class PreviewDocumentEditorModel: ObservableObject { + let editor: EditorScreenModel + + @Published private(set) var saveStatus = "" + @Published private(set) var lastOpenedLink = "" + + private let documentID: String + private let library: PreviewDocumentLibrary + private var isLoaded = false + private var lastPersistedJson: String? + private var autosaveTask: Task? + private var statusClearTask: Task? + private var linkClearTask: Task? + + init( + documentID: String, + library: PreviewDocumentLibrary, + theme: AppTheme + ) { + self.documentID = documentID + self.library = library + let editor = EditorScreenModel( + configuration: .standard(isDark: theme.isDark), + colors: theme.editorColors + ) + self.editor = editor + + editor.onDocumentChanged = { [weak self] in + self?.scheduleAutosave() + } + editor.onOpenLink = { [weak self] url in + self?.openLink(url) + } + + loadInitialDocument() + } + + private func loadInitialDocument() { + guard let document = library.document(id: documentID) else { + saveStatus = "Document unavailable" + return + } + + let result = editor.controller.loadJson(json: document.json) + if result.success { + isLoaded = true + lastPersistedJson = document.json + return + } + + // Corrupt persisted content is replaced with the deterministic bundle + // fixture once. A broken fixture is not retried, avoiding a recovery loop. + guard let seedJson = library.reset(documentID: documentID) else { + saveStatus = "Could not recover document" + return + } + let recovery = editor.controller.loadJson(json: seedJson) + isLoaded = recovery.success + if recovery.success { + lastPersistedJson = seedJson + } + if !recovery.success { + saveStatus = "Could not load document" + } + } + + private func scheduleAutosave() { + guard isLoaded else { return } + autosaveTask?.cancel() + autosaveTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(750)) + guard !Task.isCancelled else { return } + self?.autosave() + } + } + + private func autosave() { + guard isLoaded else { return } + setSaveStatus( + persistCurrentDocument() ? "Saved" : "Save failed" + ) + } + + @discardableResult + func saveOnExit() -> Bool { + autosaveTask?.cancel() + // Flush even when read-only is currently enabled: the user may have + // toggled it during the debounce window after an earlier edit. + guard isLoaded else { return true } + // A successful Back-button flush makes the onDisappear flush a no-op. + // A failed first write leaves lastPersistedJson unchanged, so the + // second lifecycle signal gets one immediate retry. + let succeeded = persistCurrentDocument() + if !succeeded { + setSaveStatus("Save failed") + } + return succeeded + } + + func reset() { + autosaveTask?.cancel() + guard let seedJson = library.reset(documentID: documentID) else { + setSaveStatus("Reset failed") + return + } + + isLoaded = false + let result = editor.controller.reset(toJson: seedJson) + isLoaded = result.success + if result.success { + lastPersistedJson = seedJson + } + setSaveStatus(result.success ? "Reset" : "Reset failed") + } + + private func persistCurrentDocument() -> Bool { + let json = editor.controller.exportJson() + if json == lastPersistedJson { + return true + } + guard library.save(json: json, for: documentID) else { + return false + } + lastPersistedJson = json + return true + } + + private func setSaveStatus(_ value: String) { + statusClearTask?.cancel() + saveStatus = value + statusClearTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + if self?.saveStatus == value { + self?.saveStatus = "" + } + } + } + + private func openLink(_ url: String) { + lastOpenedLink = url + linkClearTask?.cancel() + linkClearTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + if self?.lastOpenedLink == url { + self?.lastOpenedLink = "" + } + } + + if let parsed = URL(string: url), UIApplication.shared.canOpenURL(parsed) { + UIApplication.shared.open(parsed) + } + } +} diff --git a/iosNativeSample/iosNativeSample/Screens/Preview/PreviewDocumentLibrary.swift b/iosNativeSample/iosNativeSample/Screens/Preview/PreviewDocumentLibrary.swift new file mode 100644 index 0000000..e6472c4 --- /dev/null +++ b/iosNativeSample/iosNativeSample/Screens/Preview/PreviewDocumentLibrary.swift @@ -0,0 +1,211 @@ +import Combine +import Foundation + +struct PreviewDocument: Identifiable, Equatable { + let id: String + let title: String + let subtitle: String + let seedResource: String + var json: String +} + +private struct PreviewDocumentSeed { + let id: String + let title: String + let subtitle: String + let resource: String +} + +/// App-owned source of truth for the preview-grid integration example. +/// +/// Bundled JSON files provide deterministic first-launch content. Each document +/// receives its own editable copy under Documents/preview_showcase, and every +/// successful save updates the published snapshot used by the corresponding +/// preview card. +@MainActor +final class PreviewDocumentLibrary: ObservableObject { + @Published private(set) var documents: [PreviewDocument] = [] + @Published private(set) var lastErrorMessage = "" + + private let fileManager: FileManager + private let bundle: Bundle + private let storageDirectory: URL + + init( + fileManager: FileManager = .default, + bundle: Bundle = .main + ) { + self.fileManager = fileManager + self.bundle = bundle + + let documentsDirectory = fileManager.urls( + for: .documentDirectory, + in: .userDomainMask + )[0] + storageDirectory = documentsDirectory.appendingPathComponent( + "preview_showcase", + isDirectory: true + ) + + loadDocuments() + } + + func document(id: String) -> PreviewDocument? { + documents.first { $0.id == id } + } + + /// Persists an editor export and publishes it to the grid only after the + /// atomic file write succeeds. + @discardableResult + func save(json: String, for documentID: String) -> Bool { + guard let index = documents.firstIndex(where: { $0.id == documentID }) else { + lastErrorMessage = "Document '\(documentID)' is no longer available." + return false + } + + do { + try ensureStorageDirectory() + try json.write( + to: persistedURL(for: documentID), + atomically: true, + encoding: .utf8 + ) + documents[index].json = json + lastErrorMessage = "" + return true + } catch { + lastErrorMessage = "Could not save \(documents[index].title): \(error.localizedDescription)" + return false + } + } + + /// Restores a document's bundled fixture, persists it, and publishes the + /// restored JSON. The editor uses the returned payload for its hard reset. + func reset(documentID: String) -> String? { + guard let index = documents.firstIndex(where: { $0.id == documentID }) else { + lastErrorMessage = "Document '\(documentID)' is no longer available." + return nil + } + guard let seedJson = bundledJson(resource: documents[index].seedResource) else { + return nil + } + guard save(json: seedJson, for: documentID) else { + return nil + } + return seedJson + } + + func clearError() { + lastErrorMessage = "" + } + + private func loadDocuments() { + do { + try ensureStorageDirectory() + } catch { + lastErrorMessage = "Could not prepare preview storage: \(error.localizedDescription)" + } + + documents = Self.seeds.compactMap { seed in + let storedURL = persistedURL(for: seed.id) + let storedJson: String? + + if fileManager.fileExists(atPath: storedURL.path) { + do { + storedJson = try String(contentsOf: storedURL, encoding: .utf8) + } catch { + lastErrorMessage = "Could not read \(seed.title): \(error.localizedDescription)" + storedJson = nil + } + } else { + storedJson = nil + } + + guard let json = storedJson ?? bundledJson(resource: seed.resource) else { + return nil + } + + if storedJson == nil { + do { + try ensureStorageDirectory() + try json.write(to: storedURL, atomically: true, encoding: .utf8) + } catch { + lastErrorMessage = "Could not seed \(seed.title): \(error.localizedDescription)" + } + } + + return PreviewDocument( + id: seed.id, + title: seed.title, + subtitle: seed.subtitle, + seedResource: seed.resource, + json: json + ) + } + } + + private func bundledJson(resource: String) -> String? { + guard let url = bundle.url(forResource: resource, withExtension: "json") else { + lastErrorMessage = "Bundled preview document '\(resource).json' is missing." + return nil + } + do { + return try String(contentsOf: url, encoding: .utf8) + } catch { + lastErrorMessage = "Could not read \(resource).json: \(error.localizedDescription)" + return nil + } + } + + private func ensureStorageDirectory() throws { + try fileManager.createDirectory( + at: storageDirectory, + withIntermediateDirectories: true + ) + } + + private func persistedURL(for documentID: String) -> URL { + storageDirectory + .appendingPathComponent(documentID, isDirectory: false) + .appendingPathExtension("json") + } + + private static let seeds: [PreviewDocumentSeed] = [ + PreviewDocumentSeed( + id: "product-brief", + title: "Product brief", + subtitle: "Goals, audience, and launch notes", + resource: "preview_product_brief" + ), + PreviewDocumentSeed( + id: "weekly-plan", + title: "Weekly plan", + subtitle: "A lightweight execution checklist", + resource: "preview_weekly_plan" + ), + PreviewDocumentSeed( + id: "research-notes", + title: "Research notes", + subtitle: "Quotes, links, and open questions", + resource: "preview_research_notes" + ), + PreviewDocumentSeed( + id: "release-checklist", + title: "Release checklist", + subtitle: "The final path to production", + resource: "preview_release_checklist" + ), + PreviewDocumentSeed( + id: "code-review", + title: "Code review", + subtitle: "Implementation notes and follow-ups", + resource: "preview_code_review" + ), + PreviewDocumentSeed( + id: "trip-ideas", + title: "Trip ideas", + subtitle: "A small itinerary scratchpad", + resource: "preview_trip_ideas" + ), + ] +} diff --git a/iosNativeSample/iosNativeSample/Screens/PreviewDocumentEditorScreen.swift b/iosNativeSample/iosNativeSample/Screens/PreviewDocumentEditorScreen.swift new file mode 100644 index 0000000..129a301 --- /dev/null +++ b/iosNativeSample/iosNativeSample/Screens/PreviewDocumentEditorScreen.swift @@ -0,0 +1,109 @@ +import SwiftUI + +/// Full editor opened from a preview card. Changes are persisted back into the +/// shared document library, so navigating back reveals the refreshed preview. +struct PreviewDocumentEditorScreen: View { + @Binding var selectedTheme: SampleThemeFamily + @StateObject private var model: PreviewDocumentEditorModel + + init( + documentID: String, + library: PreviewDocumentLibrary, + selectedTheme: Binding, + initialIsDark: Bool + ) { + _selectedTheme = selectedTheme + _model = StateObject( + wrappedValue: PreviewDocumentEditorModel( + documentID: documentID, + library: library, + theme: selectedTheme.wrappedValue.appTheme(isDark: initialIsDark) + ) + ) + } + + var body: some View { + PreviewDocumentEditorContent( + selectedTheme: $selectedTheme, + model: model, + editor: model.editor + ) + } +} + +/// Observes the session and its child editor without creating a second owner. +/// The parent StateObject lazily constructs one session for the destination +/// identity, and that session exclusively owns its Cascade editor controller. +private struct PreviewDocumentEditorContent: View { + @Binding var selectedTheme: SampleThemeFamily + @ObservedObject var model: PreviewDocumentEditorModel + @ObservedObject var editor: EditorScreenModel + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase + + private var isDark: Bool { colorScheme == .dark } + private var theme: AppTheme { selectedTheme.appTheme(isDark: isDark) } + + var body: some View { + VStack(spacing: 0) { + header + ZStack(alignment: .top) { + CascadeEditorHost(model: editor) + VStack(spacing: 6) { + SavedPill(theme: theme, status: model.saveStatus) + OpenedLinkPill(theme: theme, link: model.lastOpenedLink) + } + .padding(.top, 8) + .animation(.default, value: model.saveStatus) + .animation(.default, value: model.lastOpenedLink) + } + } + .background(theme.background) + .onChange(of: scenePhase) { _, newPhase in + if newPhase != .active { + model.saveOnExit() + } + } + .onChange(of: selectedTheme) { _, newValue in + editor.applyTheme(newValue.appTheme(isDark: isDark)) + } + .onChange(of: colorScheme) { _, newValue in + editor.applyTheme(selectedTheme.appTheme(isDark: newValue == .dark)) + } + .onDisappear { + model.saveOnExit() + } + } + + @ViewBuilder + private var header: some View { + if editor.hasSelection { + SelectionTopBar( + theme: theme, + selectedCount: editor.selectedCount, + isReadOnly: editor.isReadOnly, + onCancelSelection: { editor.clearSelection() }, + onDeleteSelected: { editor.deleteSelectedOrFocused() } + ) + } else { + EditorTopBar( + theme: theme, + isReadOnly: editor.isReadOnly, + canUndo: editor.canUndo, + canRedo: editor.canRedo, + onBack: { + if model.saveOnExit() { + dismiss() + } + }, + onUndo: { editor.undo() }, + onRedo: { editor.redo() }, + onToggleReadOnly: { editor.setReadOnly(!editor.isReadOnly) }, + onToggleTheme: { selectedTheme = selectedTheme.next }, + onReset: { model.reset() } + ) + } + } +} diff --git a/iosNativeSample/iosNativeSample/Screens/PreviewGalleryScreen.swift b/iosNativeSample/iosNativeSample/Screens/PreviewGalleryScreen.swift new file mode 100644 index 0000000..ead6d4e --- /dev/null +++ b/iosNativeSample/iosNativeSample/Screens/PreviewGalleryScreen.swift @@ -0,0 +1,167 @@ +import SwiftUI + +/// Native SwiftUI grid hosting lightweight Compose document previews. +/// +/// Each cell owns one preview controller while it is alive. The outer button +/// owns taps and navigation; preview links, selection, and hit testing are +/// disabled so editor interaction begins only after the detail screen opens. +struct PreviewGalleryScreen: View { + @Binding var selectedTheme: SampleThemeFamily + @ObservedObject var library: PreviewDocumentLibrary + let onOpenDocument: (String) -> Void + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + private var isDark: Bool { colorScheme == .dark } + private var theme: AppTheme { selectedTheme.appTheme(isDark: isDark) } + private let columns = [ + GridItem(.adaptive(minimum: 160, maximum: 260), spacing: 12), + ] + + var body: some View { + VStack(spacing: 0) { + TitledEditorTopBar( + theme: theme, + title: "Preview Grid", + onBack: { dismiss() }, + onToggleTheme: { selectedTheme = selectedTheme.next } + ) + if !library.lastErrorMessage.isEmpty { + storageError + } + ScrollView { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(library.documents) { document in + PreviewDocumentCard( + document: document, + isDark: isDark, + theme: theme, + onOpen: { onOpenDocument(document.id) } + ) + } + } + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 24) + } + } + .background(theme.background) + } + + private var storageError: some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(theme.destructive) + Text(library.lastErrorMessage) + .font(.footnote) + .foregroundStyle(theme.onSurface) + .lineLimit(2) + Spacer(minLength: 4) + Button { + library.clearError() + } label: { + Image(systemName: "xmark") + .font(.caption.weight(.bold)) + .foregroundStyle(theme.mutedInk) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss storage error") + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(theme.surface) + } +} + +private struct PreviewDocumentCard: View { + let document: PreviewDocument + let isDark: Bool + let theme: AppTheme + let onOpen: () -> Void + + @StateObject private var model: PreviewCardModel + + init( + document: PreviewDocument, + isDark: Bool, + theme: AppTheme, + onOpen: @escaping () -> Void + ) { + self.document = document + self.isDark = isDark + self.theme = theme + self.onOpen = onOpen + _model = StateObject( + wrappedValue: PreviewCardModel(json: document.json, isDark: isDark) + ) + } + + var body: some View { + Button(action: onOpen) { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(document.title) + .font(.headline.weight(.semibold)) + .foregroundStyle(theme.cardTitle) + .lineLimit(1) + Spacer(minLength: 0) + Image(systemName: "arrow.up.right") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(theme.tilePreviewIcon) + } + Text(document.subtitle) + .font(.caption) + .foregroundStyle(theme.cardDescription) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + + CascadeDocumentPreviewHost(controller: model.controller) + .allowsHitTesting(false) + .frame(maxWidth: .infinity) + .frame(height: 164) + .background(theme.background.opacity(theme.isDark ? 0.42 : 0.55)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.top, 10) + .overlay(alignment: .topTrailing) { + if !model.errorMessage.isEmpty { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(theme.destructive) + .padding(.top, 16) + .padding(.trailing, 6) + .accessibilityHidden(true) + } + } + } + .padding(12) + .frame(maxWidth: .infinity, minHeight: 248, alignment: .topLeading) + .background(theme.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: 18)) + .overlay( + RoundedRectangle(cornerRadius: 18) + .strokeBorder(theme.cardBorder, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .contentShape(RoundedRectangle(cornerRadius: 18)) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(document.title). \(document.subtitle)") + .accessibilityValue( + model.errorMessage.isEmpty + ? "Preview available" + : "Preview unavailable. \(model.errorMessage)" + ) + .accessibilityHint("Opens the editable document") + .onAppear { + model.update(json: document.json, isDark: isDark) + } + .onChange(of: document.json) { _, newJson in + model.update(json: newJson, isDark: isDark) + } + .onChange(of: isDark) { _, newValue in + model.update(json: document.json, isDark: newValue) + } + } +} diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 4cd718b..d257c34 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -60,6 +60,9 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.kotlinx.datetime) + // BlockTypeCodec exposes JsonObject, so a consumer registering a custom + // block type needs kotlinx-serialization on its own compile classpath. + implementation(libs.kotlinx.serialization.json) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/sample/src/commonMain/kotlin/io/github/linreal/cascade/App.kt b/sample/src/commonMain/kotlin/io/github/linreal/cascade/App.kt index 498cf92..efba051 100644 --- a/sample/src/commonMain/kotlin/io/github/linreal/cascade/App.kt +++ b/sample/src/commonMain/kotlin/io/github/linreal/cascade/App.kt @@ -34,7 +34,10 @@ import io.github.linreal.cascade.screens.EditorDemoScreen import io.github.linreal.cascade.screens.comments.CommentsScreen import io.github.linreal.cascade.screens.external_toolbar.ExternalToolbarScreen import io.github.linreal.cascade.screens.LandingScreen +import io.github.linreal.cascade.screens.PreviewGalleryScreen import io.github.linreal.cascade.screens.markdownfield.MarkdownFieldScreen +import io.github.linreal.cascade.screens.preview.PreviewDocumentEditorScreen +import io.github.linreal.cascade.screens.preview.PreviewDocumentLibrary import io.github.linreal.cascade.theme.CascadeSampleColors import io.github.linreal.cascade.theme.LocalCascadeSampleColors import io.github.linreal.cascade.theme.sampleTypography @@ -78,6 +81,9 @@ fun App() { var currentScreen by rememberSaveable(stateSaver = AppScreenSaver) { mutableStateOf(AppScreen.Landing) } + // Hoisted above the destination switch so an edit made on the editor + // screen is still published when the gallery recomposes behind it. + val previewLibrary = remember { PreviewDocumentLibrary() } Box( modifier = Modifier @@ -88,7 +94,7 @@ fun App() { .padding(horizontal = 18.dp) .fillMaxSize(), ) { - when (currentScreen) { + when (val screen = currentScreen) { AppScreen.Landing -> LandingScreen( onNavigate = { currentScreen = it }, ) @@ -127,6 +133,22 @@ fun App() { onToggleTheme = { themeOverride = !isDark }, onBack = { currentScreen = AppScreen.Landing }, ) + AppScreen.PreviewGallery -> PreviewGalleryScreen( + library = previewLibrary, + isDark = isDark, + onToggleTheme = { themeOverride = !isDark }, + onBack = { currentScreen = AppScreen.Landing }, + onOpenDocument = { documentId -> + currentScreen = AppScreen.PreviewDocumentEditor(documentId) + }, + ) + is AppScreen.PreviewDocumentEditor -> PreviewDocumentEditorScreen( + documentId = screen.documentId, + library = previewLibrary, + isDark = isDark, + onToggleTheme = { themeOverride = !isDark }, + onBack = { currentScreen = AppScreen.PreviewGallery }, + ) } } } diff --git a/sample/src/commonMain/kotlin/io/github/linreal/cascade/navigation/AppScreen.kt b/sample/src/commonMain/kotlin/io/github/linreal/cascade/navigation/AppScreen.kt index 2d0fc54..1b2edb3 100644 --- a/sample/src/commonMain/kotlin/io/github/linreal/cascade/navigation/AppScreen.kt +++ b/sample/src/commonMain/kotlin/io/github/linreal/cascade/navigation/AppScreen.kt @@ -9,6 +9,10 @@ sealed class AppScreen { data object CustomHtmlProfile : AppScreen() data object Comments : AppScreen() data object MarkdownField : AppScreen() + data object PreviewGallery : AppScreen() + + /** Editable destination reached from a preview card, addressed by document ID. */ + data class PreviewDocumentEditor(val documentId: String) : AppScreen() internal val saveKey: String get() = when (this) { @@ -20,19 +24,35 @@ sealed class AppScreen { CustomHtmlProfile -> "custom_html_profile" Comments -> "comments" MarkdownField -> "markdown_field" + PreviewGallery -> "preview_gallery" + is PreviewDocumentEditor -> "$PREVIEW_DOCUMENT_EDITOR_PREFIX$documentId" } companion object { - internal fun fromSaveKey(saveKey: String): AppScreen = when (saveKey) { - Landing.saveKey -> Landing - EditorDemo.saveKey -> EditorDemo - CustomBlocks.saveKey -> CustomBlocks - CustomToolbar.saveKey -> CustomToolbar - ExternalToolbar.saveKey -> ExternalToolbar - CustomHtmlProfile.saveKey -> CustomHtmlProfile - Comments.saveKey -> Comments - MarkdownField.saveKey -> MarkdownField - else -> Landing + private const val PREVIEW_DOCUMENT_EDITOR_PREFIX = "preview_document:" + + internal fun fromSaveKey(saveKey: String): AppScreen { + if (saveKey.startsWith(PREVIEW_DOCUMENT_EDITOR_PREFIX)) { + val documentId = saveKey.removePrefix(PREVIEW_DOCUMENT_EDITOR_PREFIX) + // A blank ID cannot address a document; fall back to the grid. + return if (documentId.isEmpty()) { + PreviewGallery + } else { + PreviewDocumentEditor(documentId) + } + } + return when (saveKey) { + Landing.saveKey -> Landing + EditorDemo.saveKey -> EditorDemo + CustomBlocks.saveKey -> CustomBlocks + CustomToolbar.saveKey -> CustomToolbar + ExternalToolbar.saveKey -> ExternalToolbar + CustomHtmlProfile.saveKey -> CustomHtmlProfile + Comments.saveKey -> Comments + MarkdownField.saveKey -> MarkdownField + PreviewGallery.saveKey -> PreviewGallery + else -> Landing + } } } } diff --git a/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/LandingScreen.kt b/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/LandingScreen.kt index 6858cad..326733d 100644 --- a/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/LandingScreen.kt +++ b/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/LandingScreen.kt @@ -110,7 +110,7 @@ fun LandingScreen( ), ) Text( - text = "5 modules", + text = "7 modules", style = TextStyle( fontFamily = geist, fontSize = 13.sp, @@ -172,6 +172,14 @@ fun LandingScreen( description = "Test source → editor → export across Markdown codec modes", onClick = { onNavigate(AppScreen.MarkdownField) }, ) + ModuleCard( + colors = colors, + icon = Res.drawable.ic_editor, + tile = colors.tileBlocks, + title = "Preview Gallery", + description = "Render 50 static documents in a bounded grid", + onClick = { onNavigate(AppScreen.PreviewGallery) }, + ) } Spacer(modifier = Modifier.height(40.dp)) diff --git a/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/PreviewGalleryScreen.kt b/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/PreviewGalleryScreen.kt new file mode 100644 index 0000000..03a06f3 --- /dev/null +++ b/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/PreviewGalleryScreen.kt @@ -0,0 +1,223 @@ +package io.github.linreal.cascade.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockContent +import io.github.linreal.cascade.editor.registry.BlockPreviewRenderer +import io.github.linreal.cascade.editor.registry.BlockPreviewScope +import io.github.linreal.cascade.editor.registry.BlockRegistry +import io.github.linreal.cascade.editor.theme.CascadeEditorBlockStrings +import io.github.linreal.cascade.editor.theme.CascadeEditorStrings +import io.github.linreal.cascade.editor.theme.CascadeEditorTheme +import io.github.linreal.cascade.editor.ui.CascadeDocumentPreview +import io.github.linreal.cascade.editor.ui.CascadeDocumentPreviewConfig +import io.github.linreal.cascade.editor.ui.ExperimentalCascadePreviewApi +import io.github.linreal.cascade.editor.ui.createEditorRegistry +import io.github.linreal.cascade.screens.preview.PreviewDocument +import io.github.linreal.cascade.screens.preview.PreviewDocumentLibrary +import io.github.linreal.cascade.screens.preview.PreviewMetricBlockType +import io.github.linreal.cascade.theme.SampleEditorTheme +import io.github.linreal.cascade.ui.PageScaffold + +/** + * Representative note grid demonstrating the recommended preview integration: + * documents and registry are hoisted, item keys are stable, only the outer grid + * owns vertical scrolling, and a card opens the same document in a full editor. + * + * The grid never creates editor state. Tapping a card navigates by document ID; + * the destination loads that document's JSON and publishes its export back + * through [PreviewDocumentLibrary], which refreshes this card in place. + */ +@OptIn(ExperimentalCascadePreviewApi::class) +@Composable +fun PreviewGalleryScreen( + library: PreviewDocumentLibrary, + isDark: Boolean, + onToggleTheme: () -> Unit, + onBack: () -> Unit, + onOpenDocument: (String) -> Unit, +) { + // SampleEditorTheme returns a fresh but structurally equal instance per call, + // so this is already stable enough for the per-card `remember` keys inside the + // preview to survive recomposition. + val editorTheme = if (isDark) SampleEditorTheme.dark() else SampleEditorTheme.light() + val registry = remember { + createEditorRegistry().apply { + registerPreviewRenderer(PreviewMetricBlockType.typeId, PreviewMetricRenderer) + } + } + val strings = remember { CascadeEditorStrings.default() } + val blockStrings = remember { CascadeEditorBlockStrings.default() } + val previewConfig = remember { + // The card owns the tap, so link activation is disabled rather than + // arbitrated against navigation. Link spans still render styled. + CascadeDocumentPreviewConfig.GridCard.copy( + textScale = 0.8f, + linksEnabled = false, + ) + } + val documents = library.documents + + PageScaffold(maxContentWidth = 1080.dp) { + TitledEditorTopBar( + title = "Preview Gallery", + isDark = isDark, + onBack = onBack, + onToggleTheme = onToggleTheme, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${documents.size} documents · tap a card to edit it", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + if (library.lastErrorMessage.isNotEmpty()) { + Text( + text = library.lastErrorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 260.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = documents, + key = { document -> document.id }, + ) { document -> + PreviewDocumentCard( + document = document, + registry = registry, + editorTheme = editorTheme, + strings = strings, + blockStrings = blockStrings, + config = previewConfig, + onOpen = { onOpenDocument(document.id) }, + ) + } + } + } +} + +@OptIn(ExperimentalCascadePreviewApi::class) +@Composable +private fun PreviewDocumentCard( + document: PreviewDocument, + registry: BlockRegistry, + editorTheme: CascadeEditorTheme, + strings: CascadeEditorStrings, + blockStrings: CascadeEditorBlockStrings, + config: CascadeDocumentPreviewConfig, + onOpen: () -> Unit, +) { + val shape = RoundedCornerShape(18.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .height(250.dp) + .clip(shape) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) + .clickable(onClick = onOpen) + .padding(vertical = 10.dp), + ) { + Text( + text = document.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp), + ) + CascadeDocumentPreview( + blocks = document.blocks, + registry = registry, + theme = editorTheme, + strings = strings, + blockStrings = blockStrings, + config = config, + onOpenLink = null, + modifier = Modifier.padding(top = 4.dp), + ) + } +} + +@OptIn(ExperimentalCascadePreviewApi::class) +private data object PreviewMetricRenderer : BlockPreviewRenderer { + @Composable + override fun RenderPreview( + block: Block, + modifier: Modifier, + scope: BlockPreviewScope, + ) { + val custom = block.content as? BlockContent.Custom + val value = custom?.data?.get("value") as? String ?: "—" + val label = custom?.data?.get("label") as? String ?: "Metric" + Column( + modifier = modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.primaryContainer, + RoundedCornerShape(10.dp), + ) + .padding(12.dp), + ) { + BasicText( + text = value, + style = TextStyle( + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + ), + ) + BasicText( + text = label, + style = TextStyle( + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontSize = 12.sp, + ), + ) + } + } +} diff --git a/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentEditorScreen.kt b/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentEditorScreen.kt new file mode 100644 index 0000000..d6b774d --- /dev/null +++ b/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentEditorScreen.kt @@ -0,0 +1,173 @@ +package io.github.linreal.cascade.screens.preview + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import io.github.linreal.cascade.editor.registry.DefaultBlockCallbacks +import io.github.linreal.cascade.editor.serialization.loadFromJson +import io.github.linreal.cascade.editor.serialization.toJson +import io.github.linreal.cascade.editor.state.BlockSpanStates +import io.github.linreal.cascade.editor.state.BlockTextStates +import io.github.linreal.cascade.editor.state.rememberEditorState +import io.github.linreal.cascade.editor.ui.CascadeEditor +import io.github.linreal.cascade.screens.OpenedLinkPill +import io.github.linreal.cascade.screens.SavedPill +import io.github.linreal.cascade.screens.TitledEditorTopBar +import io.github.linreal.cascade.theme.SampleEditorTheme +import io.github.linreal.cascade.ui.PageScaffold +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch + +/** + * Editable destination for one document opened from the preview gallery. + * + * Editor state is created here rather than in the grid, so the gallery keeps + * paying only for static previews. Every export is published back through + * [PreviewDocumentLibrary], which is what refreshes the originating card. + */ +@OptIn(FlowPreview::class) +@Composable +fun PreviewDocumentEditorScreen( + documentId: String, + library: PreviewDocumentLibrary, + isDark: Boolean, + onToggleTheme: () -> Unit, + onBack: () -> Unit, +) { + val document = library.document(documentId) + if (document == null) { + MissingDocument(isDark = isDark, onToggleTheme = onToggleTheme, onBack = onBack) + return + } + + val editorTheme = if (isDark) SampleEditorTheme.dark() else SampleEditorTheme.light() + val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + + val textStates = remember { BlockTextStates() } + val spanStates = remember { BlockSpanStates() } + val editorState = rememberEditorState() + val callbacks = remember(editorState, textStates, spanStates) { + DefaultBlockCallbacks( + dispatchFn = { action -> editorState.dispatch(action) }, + stateProvider = { editorState.state }, + textStates = textStates, + spanStates = spanStates, + stateHolder = editorState, + ) + } + var isLoaded by remember(documentId) { mutableStateOf(false) } + var saveStatus by remember { mutableStateOf("") } + var lastOpenedLink by remember { mutableStateOf("") } + + // Load the library snapshot once per document identity. The editor owns the + // document from here on; the library is only written back to. + LaunchedEffect(documentId) { + editorState.loadFromJson( + jsonString = library.document(documentId)?.json ?: "", + textStates = textStates, + spanStates = spanStates, + typeCodec = PreviewSampleTypeCodec, + ) + isLoaded = true + } + + fun persist(): Boolean { + if (!isLoaded) return true + val json = editorState.toJson( + textStates = textStates, + spanStates = spanStates, + typeCodec = PreviewSampleTypeCodec, + ) + return library.save(documentId, json) + } + + if (isLoaded) { + LaunchedEffect(documentId) { + snapshotFlow { editorState.state } + .drop(1) + .debounce(1_000) + .collect { + saveStatus = if (persist()) "Saved" else "Save failed" + delay(2_000) + saveStatus = "" + } + } + } + + PageScaffold { + TitledEditorTopBar( + title = document.title, + isDark = isDark, + onBack = { + // Flush before leaving so the card behind this screen is already + // refreshed by the time the grid recomposes. + persist() + onBack() + }, + onToggleTheme = onToggleTheme, + ) + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (isLoaded) { + CascadeEditor( + stateHolder = editorState, + textStates = textStates, + spanStates = spanStates, + theme = editorTheme, + onOpenLink = { url -> + lastOpenedLink = url + scope.launch { + delay(3_000) + if (lastOpenedLink == url) lastOpenedLink = "" + } + runCatching { uriHandler.openUri(url) } + }, + modifier = Modifier.fillMaxSize().imePadding(), + ) + } + Column( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + SavedPill(status = saveStatus) + OpenedLinkPill(link = lastOpenedLink) + } + } + } +} + +@Composable +private fun MissingDocument( + isDark: Boolean, + onToggleTheme: () -> Unit, + onBack: () -> Unit, +) { + PageScaffold { + TitledEditorTopBar( + title = "Document unavailable", + isDark = isDark, + onBack = onBack, + onToggleTheme = onToggleTheme, + ) + } +} diff --git a/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentFixtures.kt b/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentFixtures.kt new file mode 100644 index 0000000..6b0da19 --- /dev/null +++ b/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentFixtures.kt @@ -0,0 +1,178 @@ +package io.github.linreal.cascade.screens.preview + +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockAttributes +import io.github.linreal.cascade.editor.core.BlockContent +import io.github.linreal.cascade.editor.core.BlockId +import io.github.linreal.cascade.editor.core.BlockType +import io.github.linreal.cascade.editor.core.SpanStyle +import io.github.linreal.cascade.editor.core.TextSpan +import io.github.linreal.cascade.editor.core.UnknownBlockType + +/** + * Seed fixtures for the preview gallery, cycling through the cases a preview grid + * has to survive: plain text, span-heavy rich text, nested outlines, todos, + * quote/code chrome, and custom plus unknown blocks. + */ +internal fun seedBlocks(documentId: String, index: Int): List = when (index % 6) { + 0 -> simplePreviewBlocks(documentId) + 1 -> spanHeavyPreviewBlocks(documentId) + 2 -> structuralPreviewBlocks(documentId) + 3 -> todoPreviewBlocks(documentId) + 4 -> quoteCodePreviewBlocks(documentId) + else -> customPreviewBlocks(documentId) +} + +internal fun seedTitle(index: Int): String = when (index % 6) { + 0 -> "Preview mode" + 1 -> "Rich text parity" + 2 -> "Outline" + 3 -> "Release checklist" + 4 -> "Shared primitives" + else -> "Custom + unknown" +} + " #${index + 1}" + +private fun simplePreviewBlocks(documentId: String): List = listOf( + previewBlock(documentId, 0, BlockType.Heading(2), BlockContent.Text("Preview mode")), + previewBlock( + documentId, + 1, + BlockType.Paragraph, + BlockContent.Text( + "Static document cards avoid editor state, focus, history, and nested scrolling.", + ), + ), + previewBlock( + documentId, + 2, + BlockType.Todo(checked = true), + BlockContent.Text("Hoist registry"), + ), + previewBlock(documentId, 3, BlockType.Divider, BlockContent.Empty), +) + +private fun spanHeavyPreviewBlocks(documentId: String): List { + val text = "Bold italic underline strike code mark link" + fun span(word: String, style: SpanStyle): TextSpan { + val start = text.indexOf(word) + return TextSpan(start, start + word.length, style) + } + return listOf( + previewBlock(documentId, 0, BlockType.Heading(3), BlockContent.Text("Rich text parity")), + previewBlock( + documentId, + 1, + BlockType.Paragraph, + BlockContent.Text( + text = text, + spans = listOf( + span("Bold", SpanStyle.Bold), + span("italic", SpanStyle.Italic), + span("underline", SpanStyle.Underline), + span("strike", SpanStyle.StrikeThrough), + span("code", SpanStyle.InlineCode), + span("mark", SpanStyle.Highlight(0xFFFFFF00L)), + span("link", SpanStyle.Link("https://github.com/linreal/cascade-editor")), + ), + ), + ), + previewBlock( + documentId, + 2, + BlockType.Paragraph, + BlockContent.Text("Only the link activates; the rest of the card remains host-owned."), + ), + ) +} + +private fun structuralPreviewBlocks(documentId: String): List = listOf( + previewBlock(documentId, 0, BlockType.Heading(3), BlockContent.Text("Outline")), + previewBlock(documentId, 1, BlockType.NumberedList(1), BlockContent.Text("Parent item")), + previewBlock( + documentId, + 2, + BlockType.NumberedList(1), + BlockContent.Text("Nested item"), + indentationLevel = 1, + ), + previewBlock( + documentId, + 3, + BlockType.BulletList, + BlockContent.Text("Nested bullet"), + indentationLevel = 2, + ), +) + +private fun todoPreviewBlocks(documentId: String): List = listOf( + previewBlock(documentId, 0, BlockType.Heading(3), BlockContent.Text("Release checklist")), + previewBlock(documentId, 1, BlockType.Todo(true), BlockContent.Text("Static checkbox")), + previewBlock(documentId, 2, BlockType.Todo(false), BlockContent.Text("Cross-platform QA")), + previewBlock( + documentId, + 3, + BlockType.Todo(false), + BlockContent.Text("Nested follow-up"), + indentationLevel = 1, + ), +) + +private fun quoteCodePreviewBlocks(documentId: String): List = listOf( + previewBlock( + documentId, + 0, + BlockType.Quote, + BlockContent.Text("A preview shares visual primitives, not editor runtime."), + ), + previewBlock( + documentId, + 1, + BlockType.Code, + BlockContent.Text("val blocks = loadDocument()\nrenderPreview(blocks)"), + ), + previewBlock(documentId, 2, BlockType.Divider, BlockContent.Empty), + previewBlock(documentId, 3, BlockType.Paragraph, BlockContent.Text("No internal scroll state.")), +) + +private fun customPreviewBlocks(documentId: String): List = listOf( + previewBlock( + documentId, + 0, + PreviewMetricBlockType, + BlockContent.Custom( + typeId = PreviewMetricBlockType.typeId, + data = mapOf("value" to "Shared", "label" to "Custom preview renderer"), + ), + ), + previewBlock( + documentId, + 1, + UnknownBlockType( + typeId = "future.weather", + rawTypeJson = """{"typeId":"future.weather"}""", + ), + BlockContent.Text("Unknown text remains readable through the bounded fallback."), + ), + previewBlock( + documentId, + 2, + UnknownBlockType( + typeId = "future.media", + rawTypeJson = """{"typeId":"future.media"}""", + ), + BlockContent.Custom("future.media", mapOf("asset" to "opaque")), + ), +) + +private fun previewBlock( + documentId: String, + index: Int, + type: BlockType, + content: BlockContent, + indentationLevel: Int = 0, +): Block = Block( + id = BlockId("$documentId-block-$index"), + type = type, + content = content, + attributes = BlockAttributes(indentationLevel), +) diff --git a/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentLibrary.kt b/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentLibrary.kt new file mode 100644 index 0000000..399f5a7 --- /dev/null +++ b/sample/src/commonMain/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentLibrary.kt @@ -0,0 +1,145 @@ +package io.github.linreal.cascade.screens.preview + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.github.linreal.cascade.editor.core.Block +import io.github.linreal.cascade.editor.core.BlockType +import io.github.linreal.cascade.editor.core.CustomBlockType +import io.github.linreal.cascade.editor.serialization.BlockTypeCodec +import io.github.linreal.cascade.editor.serialization.DocumentDecodeWarning +import io.github.linreal.cascade.editor.serialization.DocumentSchema +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** + * One document held by [PreviewDocumentLibrary]. + * + * [json] is the single persistence format shared by the preview cards and the + * editor destination; [blocks] is the decoded snapshot the cards render, so item + * composition never parses JSON. + */ +@Immutable +data class PreviewDocument( + val id: String, + val title: String, + val json: String, + val blocks: List, +) + +/** + * App-owned source of truth for the preview-grid integration example. + * + * Seed fixtures are encoded to JSON once at construction so that the grid and the + * editor exchange exactly one format. [save] persists an editor export and + * publishes the decoded snapshot only after the document decodes cleanly, which + * keeps the last valid snapshot on screen when a write is malformed. + * + * Replacing a single list entry keeps every other [PreviewDocument] instance + * identical, so a keyed grid recomposes one card instead of rebuilding the grid. + */ +@Stable +class PreviewDocumentLibrary(documentCount: Int = SeedDocumentCount) { + + var documents: List by mutableStateOf(seedDocuments(documentCount)) + private set + + var lastErrorMessage: String by mutableStateOf("") + private set + + fun document(id: String): PreviewDocument? = documents.firstOrNull { it.id == id } + + /** + * Persists an editor export for [id] and republishes its decoded snapshot. + * + * Returns `false` when the export does not decode, leaving the previously + * published document untouched. + */ + fun save(id: String, json: String): Boolean { + val index = documents.indexOfFirst { it.id == id } + if (index < 0) { + lastErrorMessage = "Document '$id' is no longer available." + return false + } + + val result = DocumentSchema.decodeFromStringWithReport( + jsonString = json, + typeCodec = PreviewSampleTypeCodec, + ) + val parseFailed = result.warnings.any { warning -> + warning is DocumentDecodeWarning.DocumentParseFailed + } + if (parseFailed) { + lastErrorMessage = "Could not save ${documents[index].title}." + return false + } + + documents = documents.toMutableList().also { updated -> + updated[index] = updated[index].copy(json = json, blocks = result.blocks) + } + lastErrorMessage = "" + return true + } + + fun clearError() { + lastErrorMessage = "" + } + + private fun seedDocuments(count: Int): List { + return List(count) { index -> + val id = "preview-note-$index" + val json = DocumentSchema.encodeToString( + blocks = seedBlocks(id, index), + typeCodec = PreviewSampleTypeCodec, + ) + PreviewDocument( + id = id, + title = seedTitle(index), + json = json, + // Decode the seed rather than reusing the in-memory fixture, so the + // first render already shows the same normalized outline and list + // numbering a later save publishes. Otherwise a card can shift + // subtly the first time its document is edited. + blocks = DocumentSchema.decodeFromString( + jsonString = json, + typeCodec = PreviewSampleTypeCodec, + ), + ) + } + } +} + +/** + * Sample block type behind the gallery's custom preview renderer. + * + * It lives beside the library because persistence, not rendering, is what makes a + * custom type survive the grid → editor → grid round trip. + */ +data object PreviewMetricBlockType : CustomBlockType { + override val typeId: String = "sample.preview_metric" + override val displayName: String = "Preview metric" +} + +/** + * Keeps [PreviewMetricBlockType] a first-class type across encode/decode. + * + * Without a codec the sample's custom blocks would decode as `UnknownBlockType` + * after the first save and silently fall back to the generic preview. + */ +object PreviewSampleTypeCodec : BlockTypeCodec { + override fun encodeType(type: BlockType): JsonObject? { + if (type != PreviewMetricBlockType) return null + return buildJsonObject { + put("typeId", JsonPrimitive(PreviewMetricBlockType.typeId)) + } + } + + override fun decodeType(typeId: String, json: JsonObject): BlockType? { + return PreviewMetricBlockType.takeIf { typeId == it.typeId } + } +} + +private const val SeedDocumentCount = 50 diff --git a/sample/src/commonTest/kotlin/io/github/linreal/cascade/navigation/AppScreenTest.kt b/sample/src/commonTest/kotlin/io/github/linreal/cascade/navigation/AppScreenTest.kt index adbda1d..a2e75c9 100644 --- a/sample/src/commonTest/kotlin/io/github/linreal/cascade/navigation/AppScreenTest.kt +++ b/sample/src/commonTest/kotlin/io/github/linreal/cascade/navigation/AppScreenTest.kt @@ -16,6 +16,8 @@ class AppScreenTest { AppScreen.CustomHtmlProfile, AppScreen.Comments, AppScreen.MarkdownField, + AppScreen.PreviewGallery, + AppScreen.PreviewDocumentEditor("preview-note-7"), ) for (screen in screens) { @@ -23,6 +25,21 @@ class AppScreenTest { } } + @Test + fun `preview document ids survive characters used by the save-key separator`() { + val screen = AppScreen.PreviewDocumentEditor("preview_document:odd:id") + + assertEquals(screen, AppScreen.fromSaveKey(screen.saveKey)) + } + + @Test + fun `preview document key without an id restores the gallery`() { + assertEquals( + AppScreen.PreviewGallery, + AppScreen.fromSaveKey("preview_document:"), + ) + } + @Test fun `unknown save key restores landing screen`() { assertEquals(AppScreen.Landing, AppScreen.fromSaveKey("missing")) diff --git a/sample/src/commonTest/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentLibraryTest.kt b/sample/src/commonTest/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentLibraryTest.kt new file mode 100644 index 0000000..672bd86 --- /dev/null +++ b/sample/src/commonTest/kotlin/io/github/linreal/cascade/screens/preview/PreviewDocumentLibraryTest.kt @@ -0,0 +1,121 @@ +package io.github.linreal.cascade.screens.preview + +import io.github.linreal.cascade.editor.core.BlockContent +import io.github.linreal.cascade.editor.serialization.DocumentSchema +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class PreviewDocumentLibraryTest { + + @Test + fun `every seeded document publishes blocks decoded from its own json`() { + val library = PreviewDocumentLibrary(documentCount = 12) + + assertEquals(12, library.documents.size) + for (document in library.documents) { + assertTrue(document.json.isNotEmpty(), "${document.id} has no json") + assertEquals( + DocumentSchema.decodeFromString( + jsonString = document.json, + typeCodec = PreviewSampleTypeCodec, + ), + document.blocks, + "${document.id} publishes blocks that do not match its json", + ) + } + } + + @Test + fun `custom sample block type survives the seed round trip`() { + // Index 5 is the custom/unknown fixture; without PreviewSampleTypeCodec the + // metric block would decode as UnknownBlockType and lose its preview renderer. + val library = PreviewDocumentLibrary(documentCount = 6) + val document = assertNotNull(library.document("preview-note-5")) + + assertTrue( + document.blocks.any { it.type == PreviewMetricBlockType }, + "custom block type did not survive: ${document.blocks.map { it.type.typeId }}", + ) + } + + @Test + fun `saving republishes one document and leaves the other entries identical`() { + val library = PreviewDocumentLibrary(documentCount = 4) + val before = library.documents + val target = before[1] + val editedJson = DocumentSchema.encodeToString( + blocks = target.blocks.dropLast(1), + typeCodec = PreviewSampleTypeCodec, + ) + + assertTrue(library.save(target.id, editedJson)) + + val after = library.documents + assertEquals(editedJson, after[1].json) + assertEquals(target.blocks.size - 1, after[1].blocks.size) + assertEquals("", library.lastErrorMessage) + // Keyed grid cards must not churn for documents that did not change. + assertSame(before[0], after[0]) + assertSame(before[2], after[2]) + assertSame(before[3], after[3]) + } + + @Test + fun `a malformed export keeps the last valid snapshot and reports the failure`() { + val library = PreviewDocumentLibrary(documentCount = 2) + val target = library.documents[0] + + assertFalse(library.save(target.id, "{ not json")) + + assertSame(target, library.documents[0]) + assertTrue(library.lastErrorMessage.isNotEmpty()) + + library.clearError() + assertEquals("", library.lastErrorMessage) + } + + @Test + fun `saving an unknown document id is reported and changes nothing`() { + val library = PreviewDocumentLibrary(documentCount = 2) + val before = library.documents + + assertFalse(library.save("missing", """{"version":2,"blocks":[]}""")) + + assertSame(before, library.documents) + assertTrue(library.lastErrorMessage.isNotEmpty()) + } + + @Test + fun `an empty document decodes as a successful save`() { + val library = PreviewDocumentLibrary(documentCount = 1) + val target = library.documents[0] + + assertTrue(library.save(target.id, """{"version":2,"blocks":[]}""")) + + assertEquals(emptyList(), library.documents[0].blocks) + } + + @Test + fun `text blocks with no content key decode instead of being dropped`() { + // Mirrors the preview renderers' BlockContent.Empty contract: a minimal + // document still yields one block per entry. + val library = PreviewDocumentLibrary(documentCount = 1) + val target = library.documents[0] + val minimalJson = """ + {"version":2,"blocks":[ + {"id":"a","type":{"typeId":"paragraph"}}, + {"id":"b","type":{"typeId":"todo","checked":true}} + ]} + """.trimIndent() + + assertTrue(library.save(target.id, minimalJson)) + + val blocks = library.documents[0].blocks + assertEquals(2, blocks.size) + assertTrue(blocks.all { it.content == BlockContent.Empty }) + } +}