diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index da5f850..2a485d6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -239,7 +239,7 @@ Unless a row uses an explicit module-root path such as `sample/src/...`, paths a **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. -**Editor configuration and interaction policy** — `CascadeEditorConfig` is a public behavior config passed as the final defaulted `CascadeEditor` parameter and exposed to custom renderer/chrome content through `LocalCascadeEditorConfig`. It does not replace direct ownership, styling, registry, slot, state, or callback parameters. Built-in code converts it once with `CascadeEditorConfig.toInteractionPolicy()` inside `CascadeEditor` and provides the resulting internal `EditorInteractionPolicy` through `LocalEditorInteractionPolicy`; built-in policy checks should use named capabilities from that policy instead of reading `config.readOnly` directly. Formatting, indentation, and link calculators/actions receive the policy explicitly from `CascadeEditor` so custom chrome locals expose disabled state and gated action facades from the same policy source. Their action objects also read policy through invocation-time providers, so stale formatting, indentation, link, link-popup, and toolbar-slash callbacks captured before a runtime policy change cannot keep mutating after the current policy disables them. Text-block input reads the internal policy in `TextBlockField`, forwards `readOnly = !canEditText` into the shared state-based `BackspaceAwareTextField`, and gates side-effectful Enter, sentinel-backspace, paste-history, formatting-shortcut, editor-history-shortcut, and list auto-detect paths before they reach mutation callbacks or text-history batching. The slash subsystem is gated once in `CascadeEditor` from `SlashCommandSlot` plus `canUseSlashCommands`; observer construction, popup rendering, key handling, and executor wiring use that gate, while the default toolbar slash button also requires `canEditText` before it can write to the focused text field. Block-level gesture wiring receives the policy explicitly in `blockGestures(...)`; read-only mode omits the editor-owned pointer detector, while editable configs can independently disable built-in block selection or block dragging through `blockSelectionEnabled` and `blockDraggingEnabled`. Gesture dispatch, drag/selection/drop-target actions, empty-space edit focus, and drag auto-scroll/drop/preview affordances all follow the derived capabilities. `CascadeEditor` runs one transition cleanup when the active policy disables editor-owned mutation workflows: it closes slash sessions, cancels drags, clears block selection, and dismisses editor-owned link popup sessions without changing document blocks or text focus. `TodoBlockRenderer` reads `LocalEditorInteractionPolicy` and resolves policy/callbacks at checkbox invocation time so stale checkbox lambdas cannot toggle todo state after the policy changes. The read-only policy disables every current interaction capability, while reducers and app-owned mutation surfaces remain outside this UI policy boundary. +**Editor configuration and interaction policy** — `CascadeEditorConfig` is a public behavior config passed as the final defaulted `CascadeEditor` parameter and exposed to custom renderer/chrome content through `LocalCascadeEditorConfig`. It does not replace direct ownership, styling, registry, slot, state, or callback parameters. Built-in code converts it once with `CascadeEditorConfig.toInteractionPolicy()` inside `CascadeEditor` and provides the resulting internal `EditorInteractionPolicy` through `LocalEditorInteractionPolicy`; built-in policy checks should use named capabilities from that policy instead of reading config flags directly. Formatting, indentation, and link calculators/actions receive the policy explicitly from `CascadeEditor` so custom chrome locals expose disabled state and gated action facades from the same policy source. Their action objects also read policy through invocation-time providers, so stale formatting, indentation, link, link-popup, and toolbar-slash callbacks captured before a runtime policy change cannot keep mutating after the current policy disables them. Text-block input reads the internal policy in `TextBlockField`, forwards `readOnly = !canEditText` into the shared state-based `BackspaceAwareTextField`, and gates side-effectful Enter, sentinel-backspace, paste-history, formatting-shortcut, editor-history-shortcut, and list auto-detect paths before they reach mutation callbacks or text-history batching. The slash subsystem is gated once in `CascadeEditor` from `SlashCommandSlot` plus `canUseSlashCommands`; observer construction, popup rendering, key handling, and executor wiring use that gate, while the default toolbar slash button also requires `canEditText` before it can write to the focused text field. Block-level gesture wiring receives the policy explicitly in `blockGestures(...)`; read-only mode omits the editor-owned pointer detector, while editable configs can independently disable built-in block selection, block dragging, or indentation changes through `blockSelectionEnabled`, `blockDraggingEnabled`, and `blockIndentationEnabled`. Disabling indentation keeps drag/reorder available, forces drag hover to preserve the payload's original depth, rejects destinations that would require a depth rewrite, and disables indentation command state/actions. Gesture dispatch, drag/selection/drop-target actions, empty-space edit focus, and drag auto-scroll/drop/preview affordances all follow the derived capabilities. `CascadeEditor` runs one transition cleanup when the active policy disables editor-owned mutation workflows: it closes slash sessions, cancels active drags when dragging or indentation changes become unavailable, clears block selection, and dismisses editor-owned link popup sessions without changing document blocks or text focus. `TodoBlockRenderer` reads `LocalEditorInteractionPolicy` and resolves policy/callbacks at checkbox invocation time so stale checkbox lambdas cannot toggle todo state after the policy changes. The read-only policy disables every current interaction capability, while reducers and app-owned mutation surfaces remain outside this UI policy boundary. **Read-only boundary** — read-only mode is documented in [`docs/ReadOnlyMode.md`](docs/ReadOnlyMode.md). It is intentionally a `CascadeEditor` UI boundary, not reducer-level authorization. App-owned calls such as `EditorStateHolder.dispatch(...)`, `setState(...)`, `undo()`, `redo()`, `loadFromJson(...)`, `loadFromHtml(...)`, autosave, remote sync, and direct `BlockTextStates` / `BlockSpanStates` / `TextFieldState` writes remain mutable unless the application gates them. Custom renderer and chrome code should read `LocalCascadeEditorConfig.current.readOnly`; built-in code should continue to use the internal policy. diff --git a/CHANGELOG.md b/CHANGELOG.md index 215b562..1a77236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.8.0] - 2026-07-23 + +### Added + +- Added an experimental, profile-driven Markdown codec with `MarkdownSchema` and `EditorStateHolder` import/export helpers, CommonMark/GFM-oriented block and inline support, policy-driven HTML bridging, structured warnings, bounded resource limits, source preservation for unsupported syntax, and fidelity analysis that recommends native editing or a raw-text fallback +- Added the Swift-facing `:editor-ios-sdk` static framework with a controller and UIKit host, JSON/HTML document APIs, toolbar state and actions, localization, change callbacks, native custom blocks, and native slash commands; also added an XCFramework build script and a native Swift sample +- Added `CascadeEditorConfig(blockIndentationEnabled = ...)` to disable built-in indentation commands while keeping drag-and-drop at the payload's original indentation +- Added public integration seams for external editor chrome and platform bridges: `EditorStateHolder.dispatchStructuralAction(...)`, `EditorStateHolder.resolveDocumentBlocks(...)`, `BlockRenderer.supportsDragPreview`, and stable built-in slash-command ID helpers +- Added comments-composer and Markdown round-trip examples to the sample app + +### Changed + +- Lowered the Android minimum SDK from 28 to 26 +- Updated Compose Multiplatform from 1.11.0 to 1.11.1 + +### Fixed + +- Fixed block renderers and slash commands registered after the editor is mounted not becoming available until an unrelated state change +- Fixed built-in slash conversion to non-text custom blocks so blank anchors are replaced safely and nonblank anchor text is never discarded +- Fixed JSON, HTML, and Markdown link persistence rewriting relative, fragment, `mailto:`, `tel:`, and custom-scheme targets as HTTPS URLs +- Fixed drag previews for platform-view renderers by allowing them to opt out of duplicate live composition and use a lightweight placeholder + ## [1.7.0] - 2026-06-28 ### Added diff --git a/README.md b/README.md index 88ece61..460f081 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Rich text input | HTML/JSON round-trip | Read-only rendering | Custom toolbars | [![Kotlin](https://img.shields.io/badge/Kotlin-2.3-7F52FF?logo=kotlin)](https://kotlinlang.org/docs/multiplatform.html) [![Compose](https://img.shields.io/badge/Compose_Multiplatform-1.11-4285F4?logo=jetpackcompose)](https://www.jetbrains.com/compose-multiplatform/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Android](https://img.shields.io/badge/Android-minSdk_28-3DDC84?logo=android)](https://developer.android.com/) +[![Android](https://img.shields.io/badge/Android-minSdk_26-3DDC84?logo=android)](https://developer.android.com/) [![iOS](https://img.shields.io/badge/iOS-16+-000000?logo=apple)](https://developer.apple.com/) [![Android Weekly](https://androidweekly.net/issues/issue-721/badge)](https://androidweekly.net/issues/issue-721) @@ -105,7 +105,7 @@ Some of the harder problems handled by the editor core: Start with the smallest useful setup: one paragraph block, a limited toolbar, and slash commands disabled. ```groovy -implementation("io.github.linreal:cascade-editor:1.7.0") +implementation("io.github.linreal:cascade-editor:1.8.0") ``` ```kotlin @@ -573,7 +573,7 @@ Most of this logic lives in `editor/src/commonMain`, with platform-specific code |---|---| | Kotlin | 2.3.21 | | Compose Multiplatform | 1.11.0 | -| Android minSdk | 28 | +| Android minSdk | 26 | | Android compileSdk | 36 | | iOS min version | 16.0 | | iOS targets | arm64, simulatorArm64 | diff --git a/docs/iOsNativeSdk.md b/docs/iOsNativeSdk.md index 20b7ac5..b70f1e6 100644 --- a/docs/iOsNativeSdk.md +++ b/docs/iOsNativeSdk.md @@ -128,7 +128,7 @@ The built-in slash executor also distinguishes text and non-text conversion targ ### SDK identity and configuration - `CascadeEditorSdk.version: String` — currently `"1.0.0"`. -- `CascadeEditorConfiguration` — immutable configuration containing `readOnly`, `toolbarMode`, `slashCommandsEnabled`, `blockSelectionEnabled`, `blockDraggingEnabled`, `isDark`, and `crashPolicy`. +- `CascadeEditorConfiguration` — immutable configuration containing `readOnly`, `toolbarMode`, `slashCommandsEnabled`, `blockSelectionEnabled`, `blockDraggingEnabled`, `blockIndentationEnabled`, `isDark`, and `crashPolicy`. Setting `blockIndentationEnabled` to `false` disables indentation commands and keeps block drag/reorder at the payload's original indentation depth. - `CascadeToolbarMode` — `builtIn` or `none`; `none` disables both the built-in formatting toolbar and its link popup. - `CascadeCrashPolicy` — `containAndReport` or `rethrow`. diff --git a/editor-ios-sdk/api/editor-ios-sdk.klib.api b/editor-ios-sdk/api/editor-ios-sdk.klib.api index 04c5e21..b5baf5a 100644 --- a/editor-ios-sdk/api/editor-ios-sdk.klib.api +++ b/editor-ios-sdk/api/editor-ios-sdk.klib.api @@ -152,9 +152,12 @@ final class io.github.linreal.cascade.ios.controller/CascadeDocumentLoadResult { final class io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration { // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration|null[0] constructor () // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.|(){}[0] constructor (kotlin/Boolean, io.github.linreal.cascade.ios.controller/CascadeToolbarMode, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, io.github.linreal.cascade.ios.controller/CascadeCrashPolicy) // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.|(kotlin.Boolean;io.github.linreal.cascade.ios.controller.CascadeToolbarMode;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.ios.controller.CascadeCrashPolicy){}[0] + constructor (kotlin/Boolean, io.github.linreal.cascade.ios.controller/CascadeToolbarMode, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, kotlin/Boolean, io.github.linreal.cascade.ios.controller/CascadeCrashPolicy, kotlin/Boolean) // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.|(kotlin.Boolean;io.github.linreal.cascade.ios.controller.CascadeToolbarMode;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.ios.controller.CascadeCrashPolicy;kotlin.Boolean){}[0] final val blockDraggingEnabled // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.blockDraggingEnabled|{}blockDraggingEnabled[0] final fun (): kotlin/Boolean // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.blockDraggingEnabled.|(){}[0] + final val blockIndentationEnabled // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.blockIndentationEnabled|{}blockIndentationEnabled[0] + final fun (): kotlin/Boolean // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.blockIndentationEnabled.|(){}[0] final val blockSelectionEnabled // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.blockSelectionEnabled|{}blockSelectionEnabled[0] final fun (): kotlin/Boolean // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.blockSelectionEnabled.|(){}[0] final val crashPolicy // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.crashPolicy|{}crashPolicy[0] @@ -175,7 +178,8 @@ final class io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration final fun component5(): kotlin/Boolean // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.component5|component5(){}[0] final fun component6(): kotlin/Boolean // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.component6|component6(){}[0] final fun component7(): io.github.linreal.cascade.ios.controller/CascadeCrashPolicy // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.component7|component7(){}[0] - final fun copy(kotlin/Boolean = ..., io.github.linreal.cascade.ios.controller/CascadeToolbarMode = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., io.github.linreal.cascade.ios.controller/CascadeCrashPolicy = ...): io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.copy|copy(kotlin.Boolean;io.github.linreal.cascade.ios.controller.CascadeToolbarMode;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.ios.controller.CascadeCrashPolicy){}[0] + final fun component8(): kotlin/Boolean // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.component8|component8(){}[0] + final fun copy(kotlin/Boolean = ..., io.github.linreal.cascade.ios.controller/CascadeToolbarMode = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., io.github.linreal.cascade.ios.controller/CascadeCrashPolicy = ..., kotlin/Boolean = ...): io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.copy|copy(kotlin.Boolean;io.github.linreal.cascade.ios.controller.CascadeToolbarMode;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.ios.controller.CascadeCrashPolicy;kotlin.Boolean){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // io.github.linreal.cascade.ios.controller/CascadeEditorConfiguration.toString|toString(){}[0] diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/CascadeEditorSdk.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/CascadeEditorSdk.kt index 92ec4ab..13eb567 100644 --- a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/CascadeEditorSdk.kt +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/CascadeEditorSdk.kt @@ -6,5 +6,5 @@ import kotlin.native.ObjCName @OptIn(ExperimentalObjCName::class) @ObjCName("CascadeEditorSdk", exact = true) public object CascadeEditorSdk { - public const val version: String = "1.7.0" + public const val version: String = "1.8.0" } diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorConfiguration.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorConfiguration.kt index 498b474..c3e730d 100644 --- a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorConfiguration.kt +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorConfiguration.kt @@ -30,7 +30,27 @@ public data class CascadeEditorConfiguration( public val blockDraggingEnabled: Boolean, public val isDark: Boolean, public val crashPolicy: CascadeCrashPolicy, + public val blockIndentationEnabled: Boolean, ) { + public constructor( + readOnly: Boolean, + toolbarMode: CascadeToolbarMode, + slashCommandsEnabled: Boolean, + blockSelectionEnabled: Boolean, + blockDraggingEnabled: Boolean, + isDark: Boolean, + crashPolicy: CascadeCrashPolicy, + ) : this( + readOnly = readOnly, + toolbarMode = toolbarMode, + slashCommandsEnabled = slashCommandsEnabled, + blockSelectionEnabled = blockSelectionEnabled, + blockDraggingEnabled = blockDraggingEnabled, + isDark = isDark, + crashPolicy = crashPolicy, + blockIndentationEnabled = true, + ) + public constructor() : this( readOnly = false, toolbarMode = CascadeToolbarMode.builtIn, @@ -39,6 +59,7 @@ public data class CascadeEditorConfiguration( blockDraggingEnabled = true, isDark = false, crashPolicy = CascadeCrashPolicy.containAndReport, + blockIndentationEnabled = true, ) } diff --git a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorViewController.kt b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorViewController.kt index a70fbe9..f5d093c 100644 --- a/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorViewController.kt +++ b/editor-ios-sdk/src/iosMain/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorViewController.kt @@ -50,6 +50,7 @@ public fun CascadeEditorController.makeViewController(): UIViewController = onMa readOnly = configurationState.readOnly, blockSelectionEnabled = configurationState.blockSelectionEnabled, blockDraggingEnabled = configurationState.blockDraggingEnabled, + blockIndentationEnabled = configurationState.blockIndentationEnabled, crashPolicy = configurationState.crashPolicy.toCoreCrashPolicy(), onInternalError = { error -> reportInternalError( diff --git a/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorControllerTest.kt b/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorControllerTest.kt index f600703..3d05519 100644 --- a/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorControllerTest.kt +++ b/editor-ios-sdk/src/iosSimulatorArm64Test/kotlin/io/github/linreal/cascade/ios/controller/CascadeEditorControllerTest.kt @@ -45,6 +45,7 @@ class CascadeEditorControllerTest { assertEquals(CascadeToolbarMode.builtIn, configuration.toolbarMode) assertEquals(CascadeCrashPolicy.containAndReport, configuration.crashPolicy) + assertTrue(configuration.blockIndentationEnabled) assertEquals(configuration, emptyController.configuration) assertEquals("Seed", jsonController.exportPlainText()) } diff --git a/editor/api/android/editor.api b/editor/api/android/editor.api index 0a5da4e..65beff0 100644 --- a/editor/api/android/editor.api +++ b/editor/api/android/editor.api @@ -3646,16 +3646,19 @@ public final class io/github/linreal/cascade/editor/ui/CascadeEditorConfig { public static final field Companion Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig$Companion; public fun ()V public fun (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;Z)V + public synthetic fun (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z public final fun component2 ()Z public final fun component3 ()Z public final fun component4 ()Lio/github/linreal/cascade/editor/CrashPolicy; public final fun component5 ()Lkotlin/jvm/functions/Function1; - public final fun copy (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;)Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig; - public static synthetic fun copy$default (Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig;ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig; + public final fun component6 ()Z + public final fun copy (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;Z)Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig; + public static synthetic fun copy$default (Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig;ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ZILjava/lang/Object;)Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig; public fun equals (Ljava/lang/Object;)Z public final fun getBlockDraggingEnabled ()Z + public final fun getBlockIndentationEnabled ()Z public final fun getBlockSelectionEnabled ()Z public final fun getCrashPolicy ()Lio/github/linreal/cascade/editor/CrashPolicy; public final fun getOnInternalError ()Lkotlin/jvm/functions/Function1; diff --git a/editor/api/desktop/editor.api b/editor/api/desktop/editor.api index 0a5da4e..65beff0 100644 --- a/editor/api/desktop/editor.api +++ b/editor/api/desktop/editor.api @@ -3646,16 +3646,19 @@ public final class io/github/linreal/cascade/editor/ui/CascadeEditorConfig { public static final field Companion Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig$Companion; public fun ()V public fun (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;Z)V + public synthetic fun (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z public final fun component2 ()Z public final fun component3 ()Z public final fun component4 ()Lio/github/linreal/cascade/editor/CrashPolicy; public final fun component5 ()Lkotlin/jvm/functions/Function1; - public final fun copy (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;)Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig; - public static synthetic fun copy$default (Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig;ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig; + public final fun component6 ()Z + public final fun copy (ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;Z)Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig; + public static synthetic fun copy$default (Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig;ZZZLio/github/linreal/cascade/editor/CrashPolicy;Lkotlin/jvm/functions/Function1;ZILjava/lang/Object;)Lio/github/linreal/cascade/editor/ui/CascadeEditorConfig; public fun equals (Ljava/lang/Object;)Z public final fun getBlockDraggingEnabled ()Z + public final fun getBlockIndentationEnabled ()Z public final fun getBlockSelectionEnabled ()Z public final fun getCrashPolicy ()Lio/github/linreal/cascade/editor/CrashPolicy; public final fun getOnInternalError ()Lkotlin/jvm/functions/Function1; diff --git a/editor/api/editor.klib.api b/editor/api/editor.klib.api index 9bde8d0..3474276 100644 --- a/editor/api/editor.klib.api +++ b/editor/api/editor.klib.api @@ -2960,10 +2960,13 @@ final class io.github.linreal.cascade.editor.ui.renderers/TodoBlockRenderer : io } 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? = ...) // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.|(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.editor.CrashPolicy;kotlin.Function1?){}[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] final val blockDraggingEnabled // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.blockDraggingEnabled|{}blockDraggingEnabled[0] final fun (): kotlin/Boolean // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.blockDraggingEnabled.|(){}[0] + final val blockIndentationEnabled // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.blockIndentationEnabled|{}blockIndentationEnabled[0] + final fun (): kotlin/Boolean // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.blockIndentationEnabled.|(){}[0] final val blockSelectionEnabled // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.blockSelectionEnabled|{}blockSelectionEnabled[0] final fun (): kotlin/Boolean // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.blockSelectionEnabled.|(){}[0] final val crashPolicy // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.crashPolicy|{}crashPolicy[0] @@ -2978,7 +2981,8 @@ final class io.github.linreal.cascade.editor.ui/CascadeEditorConfig { // io.gith final fun component3(): kotlin/Boolean // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.component3|component3(){}[0] final fun component4(): io.github.linreal.cascade.editor/CrashPolicy // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.component4|component4(){}[0] final fun component5(): kotlin/Function1? // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.component5|component5(){}[0] - final fun copy(kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., io.github.linreal.cascade.editor/CrashPolicy = ..., kotlin/Function1? = ...): io.github.linreal.cascade.editor.ui/CascadeEditorConfig // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.copy|copy(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.editor.CrashPolicy;kotlin.Function1?){}[0] + final fun component6(): kotlin/Boolean // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.component6|component6(){}[0] + final fun copy(kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., io.github.linreal.cascade.editor/CrashPolicy = ..., kotlin/Function1? = ..., kotlin/Boolean = ...): io.github.linreal.cascade.editor.ui/CascadeEditorConfig // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.copy|copy(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;io.github.linreal.cascade.editor.CrashPolicy;kotlin.Function1?;kotlin.Boolean){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // io.github.linreal.cascade.editor.ui/CascadeEditorConfig.toString|toString(){}[0] diff --git a/editor/build.gradle.kts b/editor/build.gradle.kts index 509fb75..ae910d4 100644 --- a/editor/build.gradle.kts +++ b/editor/build.gradle.kts @@ -110,7 +110,7 @@ mavenPublishing { coordinates( groupId = "io.github.linreal", artifactId = "cascade-editor", - version = "1.7.0" + version = "1.8.0" ) pom { diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/indentation/DefaultIndentationActions.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/indentation/DefaultIndentationActions.kt index 085160a..2b34150 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/indentation/DefaultIndentationActions.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/indentation/DefaultIndentationActions.kt @@ -32,14 +32,14 @@ internal class DefaultIndentationActions( override fun indentForward() { val policy = policyProvider() - if (!policy.canEditBlockStructure) return + if (!policy.canChangeBlockIndentation) return if (!stateProvider().canIndentForward) return dispatchAction(IndentForward) } override fun indentBackward() { val policy = policyProvider() - if (!policy.canEditBlockStructure) return + if (!policy.canChangeBlockIndentation) return if (!stateProvider().canIndentBackward) return dispatchAction(IndentBackward) } diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/indentation/IndentationStateCalculator.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/indentation/IndentationStateCalculator.kt index efde245..fda5f18 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/indentation/IndentationStateCalculator.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/indentation/IndentationStateCalculator.kt @@ -54,7 +54,7 @@ internal object IndentationStateCalculator { if (rootIndices.isEmpty()) return IndentationState.Empty val targetBlockIds = rootIndices.map { index -> blocks[index].id } - if (!policy.canEditBlockStructure) { + if (!policy.canChangeBlockIndentation) { return IndentationState( canIndentForward = false, canIndentBackward = false, diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/AutoScrollEffect.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/AutoScrollEffect.kt index 7e08c59..e5a7ac4 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/AutoScrollEffect.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/AutoScrollEffect.kt @@ -94,6 +94,8 @@ internal fun calculateAutoScrollAmount( * @param outlineIndexProvider Provides cached block lookup data keyed by the * current block list. * @param indentUnitPx Width of one outline indentation step in pixels + * @param allowIndentationChange Whether horizontal drag movement may change the + * payload's indentation level * @param onDropTargetChanged Callback to dispatch the resolved hover target */ @Composable @@ -105,6 +107,7 @@ internal fun AutoScrollDuringDrag( stateProvider: () -> EditorState, outlineIndexProvider: () -> DragHoverOutlineIndex, indentUnitPx: Float, + allowIndentationChange: Boolean, onDropTargetChanged: (DragHoverTarget?) -> Unit ) { val density = LocalDensity.current @@ -150,6 +153,7 @@ internal fun AutoScrollDuringDrag( dragState = currentState.dragState, horizontalDragDeltaPx = dragDeltaX(), indentUnitPx = indentUnitPx, + allowIndentationChange = allowIndentationChange, ) onDropTargetChanged(hoverTarget) } diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/BlockGestureModifier.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/BlockGestureModifier.kt index b8e29da..fd9366a 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/BlockGestureModifier.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/BlockGestureModifier.kt @@ -46,6 +46,8 @@ import kotlinx.coroutines.CancellationException * Updated during drag; read by [AutoScrollDuringDrag] for depth-aware hover * recalculation after scroll shifts the visible items. * @param indentUnitPx Width of one outline indentation step in pixels. + * @param allowIndentationChange Whether horizontal drag movement may change the + * payload's indentation level. * @param stateProvider Provides the current [EditorState] for drag status * and block count. * @param outlineIndexProvider Provides cached block lookup data keyed by the @@ -60,6 +62,7 @@ internal fun Modifier.blockGestures( dragOffsetY: MutableFloatState, dragDeltaX: MutableFloatState, indentUnitPx: Float, + allowIndentationChange: Boolean, stateProvider: () -> EditorState, outlineIndexProvider: () -> DragHoverOutlineIndex, callbacks: BlockCallbacks, @@ -158,6 +161,7 @@ internal fun Modifier.blockGestures( dragState = currentState.dragState, horizontalDragDeltaPx = dragDeltaX.floatValue, indentUnitPx = indentUnitPx, + allowIndentationChange = allowIndentationChange, ) dispatchDragTargetUpdateIfAllowed( policy = policy, diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeEditor.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeEditor.kt index f5240a5..25e24e6 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeEditor.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeEditor.kt @@ -534,6 +534,7 @@ public fun CascadeEditor( dragOffsetY = dragOffsetY, dragDeltaX = dragDeltaX, indentUnitPx = indentUnitPx, + allowIndentationChange = interactionPolicy.canChangeBlockIndentation, stateProvider = { stateHolder.state }, outlineIndexProvider = { currentDragHoverOutlineIndex }, callbacks = callbacks, @@ -643,6 +644,7 @@ public fun CascadeEditor( stateProvider = { stateHolder.state }, outlineIndexProvider = { currentDragHoverOutlineIndex }, indentUnitPx = indentUnitPx, + allowIndentationChange = interactionPolicy.canChangeBlockIndentation, onDropTargetChanged = { hoverTarget -> dispatchDragTargetUpdateIfAllowed( policy = interactionPolicy, @@ -843,7 +845,10 @@ internal fun applyReadOnlyTransitionCleanup( if (!policy.canUseSlashCommands && stateHolder.state.slashCommandState != null) { stateHolder.dispatch(CloseSlashCommand) } - if (!policy.canDragBlocks && stateHolder.state.dragState != null) { + if ( + (!policy.canDragBlocks || !policy.canChangeBlockIndentation) && + stateHolder.state.dragState != null + ) { stateHolder.dispatch(CancelDrag) } if (!policy.canSelectBlocks && stateHolder.state.selectedBlockIds.isNotEmpty()) { diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeEditorConfig.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeEditorConfig.kt index 5e195f6..42cd0bb 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeEditorConfig.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/CascadeEditorConfig.kt @@ -20,6 +20,10 @@ import io.github.linreal.cascade.editor.CrashPolicy * @param blockDraggingEnabled When `false`, built-in block dragging and drag * affordances are disabled. Ignored when [readOnly] is `true`, because * read-only mode disables block dragging regardless of this value. + * @param blockIndentationEnabled When `false`, built-in indentation commands + * are disabled and block dragging preserves the payload's original + * indentation. Ignored when [readOnly] is `true`, because read-only mode + * disables indentation changes regardless of this value. */ @Immutable public data class CascadeEditorConfig( @@ -37,7 +41,27 @@ public data class CascadeEditorConfig( * host's own crash reporting). Never invoked under [CrashPolicy.Rethrow]. */ val onInternalError: CascadeErrorReporter? = null, + val blockIndentationEnabled: Boolean = true, ) { + /** + * Compatibility constructor preserving the pre-indentation-configuration + * parameter order for binary callers. + */ + public constructor( + readOnly: Boolean, + blockSelectionEnabled: Boolean, + blockDraggingEnabled: Boolean, + crashPolicy: CrashPolicy, + onInternalError: CascadeErrorReporter?, + ) : this( + readOnly = readOnly, + blockSelectionEnabled = blockSelectionEnabled, + blockDraggingEnabled = blockDraggingEnabled, + crashPolicy = crashPolicy, + onInternalError = onInternalError, + blockIndentationEnabled = true, + ) + public companion object { /** Editable default used when callers do not provide a config. */ public val Default: CascadeEditorConfig = CascadeEditorConfig() diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/EditorInteractionPolicy.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/EditorInteractionPolicy.kt index 9227557..b37a2cd 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/EditorInteractionPolicy.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/EditorInteractionPolicy.kt @@ -20,6 +20,7 @@ internal data class EditorInteractionPolicy( val canUseSlashCommands: Boolean, val canSelectBlocks: Boolean, val canDragBlocks: Boolean, + val canChangeBlockIndentation: Boolean, ) { internal companion object { internal val Editable: EditorInteractionPolicy = EditorInteractionPolicy( @@ -32,6 +33,7 @@ internal data class EditorInteractionPolicy( canUseSlashCommands = true, canSelectBlocks = true, canDragBlocks = true, + canChangeBlockIndentation = true, ) internal val ReadOnly: EditorInteractionPolicy = EditorInteractionPolicy( @@ -44,6 +46,7 @@ internal data class EditorInteractionPolicy( canUseSlashCommands = false, canSelectBlocks = false, canDragBlocks = false, + canChangeBlockIndentation = false, ) } } @@ -57,12 +60,17 @@ internal data class EditorInteractionPolicy( internal fun CascadeEditorConfig.toInteractionPolicy(): EditorInteractionPolicy { return if (readOnly) { EditorInteractionPolicy.ReadOnly - } else if (blockSelectionEnabled && blockDraggingEnabled) { + } else if ( + blockSelectionEnabled && + blockDraggingEnabled && + blockIndentationEnabled + ) { EditorInteractionPolicy.Editable } else { EditorInteractionPolicy.Editable.copy( canSelectBlocks = blockSelectionEnabled, canDragBlocks = blockDraggingEnabled, + canChangeBlockIndentation = blockIndentationEnabled, ) } } diff --git a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/utils/DragUtils.kt b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/utils/DragUtils.kt index b509ea4..a6f4e21 100644 --- a/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/utils/DragUtils.kt +++ b/editor/src/commonMain/kotlin/io/github/linreal/cascade/editor/ui/utils/DragUtils.kt @@ -157,6 +157,10 @@ public fun calculateDropTargetIndex( * the persisted depth range or make following non-dragged blocks become accidental * descendants of the moved payload. * + * When [allowIndentationChange] is `false`, horizontal movement is ignored and the + * primary root must keep its original depth. A gap that cannot accept the payload at + * that depth is rejected instead of silently rewriting indentation. + * * Returns `null` when the visual gap is inside the dragged payload or when no depth can * satisfy the surrounding outline constraints. */ @@ -166,6 +170,7 @@ internal fun resolveDepthAwareDragHoverTarget( visualGap: Int?, horizontalDragDeltaPx: Float, indentUnitPx: Float, + allowIndentationChange: Boolean = true, ): DragHoverTarget? { return resolveDepthAwareDragHoverTarget( outlineIndex = DragHoverOutlineIndex(blocks), @@ -173,6 +178,7 @@ internal fun resolveDepthAwareDragHoverTarget( visualGap = visualGap, horizontalDragDeltaPx = horizontalDragDeltaPx, indentUnitPx = indentUnitPx, + allowIndentationChange = allowIndentationChange, ) } @@ -182,6 +188,7 @@ internal fun resolveDepthAwareDragHoverTarget( visualGap: Int?, horizontalDragDeltaPx: Float, indentUnitPx: Float, + allowIndentationChange: Boolean = true, ): DragHoverTarget? { val currentDragState = dragState ?: return null val blocks = outlineIndex.blocks @@ -216,10 +223,10 @@ internal fun resolveDepthAwareDragHoverTarget( ?: primaryRoot.attributes.indentationLevel val primarySupportsIndentation = currentDragState.primaryRootSupportsIndentation ?: primaryRoot.type.supportsIndentation - val requestedDepth = if (primarySupportsIndentation) { + val requestedDepth = if (primarySupportsIndentation && allowIndentationChange) { originalPrimaryDepth + horizontalIndentSteps(horizontalDragDeltaPx, indentUnitPx) } else { - BlockAttributes.MIN_INDENTATION_LEVEL + originalPrimaryDepth } val payloadDepthOffsetRange = @@ -252,6 +259,7 @@ internal fun resolveDepthAwareDragHoverTarget( } if (minDepth > maxDepth) return null + if (!allowIndentationChange && requestedDepth !in minDepth..maxDepth) return null return DragHoverTarget( visualGap = gap, @@ -272,6 +280,7 @@ internal fun recomputeDepthAwareDragHoverTarget( dragState: DragState?, horizontalDragDeltaPx: Float, indentUnitPx: Float, + allowIndentationChange: Boolean = true, ): DragHoverTarget? { return recomputeDepthAwareDragHoverTarget( layoutInfo = layoutInfo, @@ -280,6 +289,7 @@ internal fun recomputeDepthAwareDragHoverTarget( dragState = dragState, horizontalDragDeltaPx = horizontalDragDeltaPx, indentUnitPx = indentUnitPx, + allowIndentationChange = allowIndentationChange, ) } @@ -290,6 +300,7 @@ internal fun recomputeDepthAwareDragHoverTarget( dragState: DragState?, horizontalDragDeltaPx: Float, indentUnitPx: Float, + allowIndentationChange: Boolean = true, ): DragHoverTarget? { val visualGap = calculateDropTargetIndex( layoutInfo = layoutInfo, @@ -302,6 +313,7 @@ internal fun recomputeDepthAwareDragHoverTarget( visualGap = visualGap, horizontalDragDeltaPx = horizontalDragDeltaPx, indentUnitPx = indentUnitPx, + allowIndentationChange = allowIndentationChange, ) } diff --git a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/CascadeEditorConfigTest.kt b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/CascadeEditorConfigTest.kt index 3084933..27714e0 100644 --- a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/CascadeEditorConfigTest.kt +++ b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/CascadeEditorConfigTest.kt @@ -13,11 +13,13 @@ class CascadeEditorConfigTest { assertFalse(CascadeEditorConfig.Default.readOnly) assertTrue(CascadeEditorConfig.Default.blockSelectionEnabled) assertTrue(CascadeEditorConfig.Default.blockDraggingEnabled) + assertTrue(CascadeEditorConfig.Default.blockIndentationEnabled) assertEquals( CascadeEditorConfig( readOnly = false, blockSelectionEnabled = true, blockDraggingEnabled = true, + blockIndentationEnabled = true, ), CascadeEditorConfig.Default, ) diff --git a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/DefaultIndentationActionsTest.kt b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/DefaultIndentationActionsTest.kt index 08a976a..4a7f5ed 100644 --- a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/DefaultIndentationActionsTest.kt +++ b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/DefaultIndentationActionsTest.kt @@ -123,4 +123,26 @@ class DefaultIndentationActionsTest { assertTrue(dispatchedActions.isEmpty()) } + + @Test + fun `disabled indentation capability blocks dispatch while editing remains enabled`() { + val dispatchedActions = mutableListOf() + val enabledState = IndentationState( + canIndentForward = true, + canIndentBackward = true, + targetBlockIds = listOf(targetId), + ) + val actions = DefaultIndentationActions( + stateProvider = { enabledState }, + dispatchAction = dispatchedActions::add, + policy = EditorInteractionPolicy.Editable.copy( + canChangeBlockIndentation = false, + ), + ) + + actions.indentForward() + actions.indentBackward() + + assertTrue(dispatchedActions.isEmpty()) + } } diff --git a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/DragUtilsTest.kt b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/DragUtilsTest.kt index f555836..d6a3dde 100644 --- a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/DragUtilsTest.kt +++ b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/DragUtilsTest.kt @@ -193,6 +193,56 @@ class DragUtilsTest { assertEquals(1, target?.futureRootIndentationLevel) } + @Test + fun `disabled indentation ignores horizontal drag and preserves original depth`() { + val state = draggingState( + blocks = listOf( + block("A", depth = 0), + block("B", depth = 1), + block("C", depth = 0), + ), + draggedBlockId = "B", + ) + + val target = resolveDepthAwareDragHoverTarget( + blocks = state.blocks, + dragState = state.dragState, + visualGap = 1, + horizontalDragDeltaPx = 96f, + indentUnitPx = 24f, + allowIndentationChange = false, + ) + + assertEquals(1, target?.visualGap) + assertEquals(1, target?.futureRootIndentationLevel) + } + + @Test + fun `disabled indentation rejects destination requiring an implicit depth change`() { + val state = draggingState( + blocks = listOf( + block("A", depth = 0), + block("B", depth = 1), + block("C", depth = 2), + block("D", depth = 0), + block("E", depth = 1), + block("F", depth = 2), + ), + draggedBlockId = "D", + ) + + val target = resolveDepthAwareDragHoverTarget( + blocks = state.blocks, + dragState = state.dragState, + visualGap = 2, + horizontalDragDeltaPx = 0f, + indentUnitPx = 24f, + allowIndentationChange = false, + ) + + assertNull(target) + } + @Test fun `hover target keeps unsupported primary root at depth zero during horizontal drag`() { val state = draggingState( diff --git a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/EditorInteractionPolicyTest.kt b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/EditorInteractionPolicyTest.kt index 98b46e4..0040289 100644 --- a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/EditorInteractionPolicyTest.kt +++ b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/EditorInteractionPolicyTest.kt @@ -26,6 +26,7 @@ class EditorInteractionPolicyTest { assertAllCapabilities(policy, expected = false) assertFalse(policy.canSelectBlocks) assertFalse(policy.canDragBlocks) + assertFalse(policy.canChangeBlockIndentation) } @Test @@ -79,6 +80,19 @@ class EditorInteractionPolicyTest { assertFalse(policy.canDragBlocks) } + @Test + fun `editable config can disable block indentation without disabling dragging`() { + val policy = CascadeEditorConfig( + readOnly = false, + blockDraggingEnabled = true, + blockIndentationEnabled = false, + ).toInteractionPolicy() + + assertTrue(policy.canEditBlockStructure) + assertTrue(policy.canDragBlocks) + assertFalse(policy.canChangeBlockIndentation) + } + private fun assertAllCapabilities(policy: EditorInteractionPolicy, expected: Boolean) { val assertions = listOf( policy.canEditText, @@ -90,6 +104,7 @@ class EditorInteractionPolicyTest { policy.canUseSlashCommands, policy.canSelectBlocks, policy.canDragBlocks, + policy.canChangeBlockIndentation, ) assertions.forEach { actual -> diff --git a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/IndentationStateCalculatorTest.kt b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/IndentationStateCalculatorTest.kt index bf8cad8..db3b49e 100644 --- a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/IndentationStateCalculatorTest.kt +++ b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/IndentationStateCalculatorTest.kt @@ -154,4 +154,24 @@ class IndentationStateCalculatorTest { assertFalse(result.canIndentBackward) assertEquals(listOf(second.id), result.targetBlockIds) } + + @Test + fun `disabled indentation capability preserves targets but disables commands`() { + val first = block("first") + val second = block("second") + + val result = IndentationStateCalculator.compute( + state = state( + blocks = listOf(first, second), + focusedBlockId = second.id, + ), + policy = EditorInteractionPolicy.Editable.copy( + canChangeBlockIndentation = false, + ), + ) + + assertFalse(result.canIndentForward) + assertFalse(result.canIndentBackward) + assertEquals(listOf(second.id), result.targetBlockIds) + } } diff --git a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/ReadOnlyRuntimeTransitionTest.kt b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/ReadOnlyRuntimeTransitionTest.kt index cc23482..c67af49 100644 --- a/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/ReadOnlyRuntimeTransitionTest.kt +++ b/editor/src/commonTest/kotlin/io/github/linreal/cascade/editor/ReadOnlyRuntimeTransitionTest.kt @@ -162,6 +162,25 @@ class ReadOnlyRuntimeTransitionTest { assertEquals(setOf(otherBlockId), holder.state.selectedBlockIds) } + @Test + fun `disabling indentation cancels an active drag without disabling future dragging`() { + val dragging = StartDrag(blockId, touchOffsetY = 0f) + .reduce(EditorState.withBlocks(listOf(block, otherBlock))) + val holder = EditorStateHolder(dragging) + val policy = EditorInteractionPolicy.Editable.copy( + canChangeBlockIndentation = false, + ) + + applyReadOnlyTransitionCleanup( + policy = policy, + stateHolder = holder, + linkPopupController = popupController(), + ) + + assertTrue(policy.canDragBlocks) + assertNull(holder.state.dragState) + } + @Test fun `captured formatting action reads latest read-only policy before mutating`() { var policy = EditorInteractionPolicy.Editable diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f6925b6..f0b1e4e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] agp = "8.13.2" android-compileSdk = "36" -android-minSdk = "28" +android-minSdk = "26" android-targetSdk = "36" androidx-activity = "1.13.0" androidx-lifecycle = "2.10.0"