diff --git a/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt b/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt index 9a2b922c..f458f694 100644 --- a/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt +++ b/app/src/main/java/com/ethran/notable/editor/EditorControlTower.kt @@ -9,8 +9,8 @@ import com.ethran.notable.editor.state.EditorState import com.ethran.notable.editor.state.History import com.ethran.notable.editor.state.HistoryBusActions import com.ethran.notable.editor.state.Mode -import com.ethran.notable.editor.state.Operation import com.ethran.notable.editor.state.PlacementMode +import com.ethran.notable.editor.state.Operation import com.ethran.notable.editor.state.SelectionState import com.ethran.notable.editor.state.UndoRedoType import com.ethran.notable.editor.utils.divideStrokesFromCut @@ -90,19 +90,9 @@ class EditorControlTower( * @param id The unique identifier of the page to switch to. */ private suspend fun switchPage(id: String) { - // TODO: Check if this is a problem: - //`switchPage()` now calls `page.changePage(id)` inside `withContext(Dispatchers.IO)`, - // but `PageView.changePage()` mutates in-memory state (e.g., `currentPageId`, - // `zoomLevel.value`) before it does its own IO work. Calling it from IO risks - // off-main state mutations and also makes it easier for callers to accidentally - // invoke `page.changePage()` twice (which currently happens in `registerObservers()`). - // Prefer calling `page.changePage(id)` from the main thread (or let - // `PageView.changePage()` manage its own dispatching) and ensure callers don’t call - // it again after `switchPage()`. - // Switch to Main thread for Compose state mutations withContext(Dispatchers.Main) { - state.changePage(id) + state.viewModel.changePage(id) history.cleanHistory() } @@ -121,11 +111,11 @@ class EditorControlTower( } fun toggleTool() { - state.mode = if (state.mode == Mode.Draw) Mode.Erase else Mode.Draw + state.viewModel.onToolbarAction(ToolbarAction.ChangeMode(if (state.mode == Mode.Draw) Mode.Erase else Mode.Draw)) } fun toggleZen() { - state.isToolbarOpen = !state.isToolbarOpen + state.viewModel.onToolbarAction(ToolbarAction.ToggleToolbar) } fun getSnapshotOfSelectionState(): SelectionState { @@ -137,21 +127,15 @@ class EditorControlTower( } fun goToNextPage() { - scope.launch(Dispatchers.IO) { - logEditorControlTower.i("Going to next page") - val next = state.getNextPage() - if (next != null) - switchPage(next) - } + logEditorControlTower.i("Going to next page") + state.viewModel.goToNextPage() + history.cleanHistory() } fun goToPreviousPage() { - scope.launch(Dispatchers.IO) { - logEditorControlTower.i("Going to previous page") - val previous = state.getPreviousPage() - if (previous != null) - switchPage(previous) - } + logEditorControlTower.i("Going to previous page") + state.viewModel.goToPreviousPage() + history.cleanHistory() } fun undo() { @@ -325,5 +309,4 @@ class EditorControlTower( showHint("Pasted content from clipboard", scope) } -} - +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/editor/EditorView.kt b/app/src/main/java/com/ethran/notable/editor/EditorView.kt index a5c28149..50608bdd 100644 --- a/app/src/main/java/com/ethran/notable/editor/EditorView.kt +++ b/app/src/main/java/com/ethran/notable/editor/EditorView.kt @@ -8,15 +8,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState 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.Modifier import androidx.compose.ui.platform.LocalContext import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.ethran.notable.data.AppRepository import com.ethran.notable.data.datastore.EditorSettingCacheManager @@ -42,6 +43,9 @@ import com.ethran.notable.ui.views.LibraryDestination import com.ethran.notable.ui.views.PagesDestination import io.shipbook.shipbooksdk.ShipBook import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -73,6 +77,7 @@ fun EditorView( appRepository: AppRepository, bookId: String?, pageId: String, + isQuickNavOpen: Boolean, onPageChange: (String) -> Unit, viewModel: EditorViewModel = hiltViewModel() ) { @@ -107,13 +112,17 @@ fun EditorView( } } + // Sync isQuickNavOpen to ViewModel + LaunchedEffect(isQuickNavOpen) { + viewModel.onToolbarAction(ToolbarAction.UpdateQuickNavOpen(isQuickNavOpen)) + } + if (pageExists == null) return BoxWithConstraints { val height = convertDpToPixel(this.maxHeight, context).toInt() val width = convertDpToPixel(this.maxWidth, context).toInt() - val page = remember { PageView( context = context, @@ -126,37 +135,29 @@ fun EditorView( ) } - val editorState = remember { - EditorState( - appRepository = appRepository, - bookId = bookId, - pageId = pageId, - pageView = page, - persistedEditorSettings = editorSettingCacheManager.getEditorSettings(), - onPageChange = onPageChange - ) - } - val history = remember { History(page) } + + // Create EditorState wrapper for backward compatibility + val editorState = remember(viewModel, page) { + EditorState(viewModel) + } + + // Initialize ViewModel with persisted settings on first composition + LaunchedEffect(Unit) { + viewModel.initFromPersistedSettings(editorSettingCacheManager.getEditorSettings()) + viewModel.updateDrawingState() + } + val editorControlTower = remember { EditorControlTower(scope, page, history, editorState).apply { registerObservers() } } - // Collect UI Events from ViewModel + // Collect UI Events from ViewModel (navigation and snackbars) LaunchedEffect(Unit) { viewModel.uiEvents.collect { event -> when (event) { - is EditorUiEvent.Undo -> editorControlTower.undo() - is EditorUiEvent.Redo -> editorControlTower.redo() - is EditorUiEvent.Paste -> editorControlTower.pasteFromClipboard() - is EditorUiEvent.ResetView -> editorControlTower.resetZoomAndScroll() - is EditorUiEvent.ClearAllStrokes -> { - CanvasEventBus.clearPageSignal.emit(Unit) - snackManager.displaySnack(SnackConf(text = "Cleared all strokes")) - } - is EditorUiEvent.NavigateToLibrary -> { navController.navigate(LibraryDestination.createRoute(event.folderId)) } @@ -170,37 +171,31 @@ fun EditorView( } is EditorUiEvent.ShowSnackbar -> { - snackManager.displaySnack(SnackConf(text = event.message)) + snackManager.displaySnack(SnackConf(text = event.message, duration = 2000)) } + } + } + } - is EditorUiEvent.CopyImageToCanvas -> { - CanvasEventBus.addImageByUri.value = event.uri + // Collect Canvas Commands from ViewModel + LaunchedEffect(Unit) { + viewModel.canvasCommands.collect { command -> + when (command) { + CanvasCommand.Undo -> editorControlTower.undo() + CanvasCommand.Redo -> editorControlTower.redo() + CanvasCommand.Paste -> editorControlTower.pasteFromClipboard() + CanvasCommand.ResetView -> editorControlTower.resetZoomAndScroll() + CanvasCommand.ClearAllStrokes -> { + CanvasEventBus.clearPageSignal.emit(Unit) + snackManager.displaySnack(SnackConf(text = "Cleared all strokes")) } - EditorUiEvent.RefreshCanvas -> { + CanvasCommand.RefreshCanvas -> { CanvasEventBus.reloadFromDb.emit(Unit) } - is EditorUiEvent.ModeChanged -> { - editorState.mode = event.mode - } - - is EditorUiEvent.PenChanged -> { - editorState.pen = event.pen - } - - is EditorUiEvent.PenSettingChanged -> { - val newSettings = editorState.penSettings.toMutableMap() - newSettings[event.pen.penName] = event.setting - editorState.penSettings = newSettings - } - - is EditorUiEvent.EraserChanged -> { - editorState.eraser = event.eraser - } - - is EditorUiEvent.ToolbarVisibilityChanged -> { - editorState.isToolbarOpen = event.visible + is CanvasCommand.CopyImageToCanvas -> { + CanvasEventBus.addImageByUri.value = command.uri } } } @@ -222,38 +217,39 @@ fun EditorView( } } - // Sync legacy state to ViewModel for Toolbar rendering - val zoomLevel by page.zoomLevel.collectAsState() - val selectionActive = editorState.selectionState.isNonEmpty() + // Collect toolbar state and sync EditorState (keeps snapshotFlow observers in canvas alive) + val toolbarState by viewModel.toolbarState.collectAsStateWithLifecycle() + LaunchedEffect(toolbarState) { + editorState.syncFrom(toolbarState) + } + + // Observe pageId changes from ViewModel state for navigation + LaunchedEffect(viewModel) { + snapshotFlow { toolbarState.pageId } + .filterNotNull() + .distinctUntilChanged() + .drop(1) // Skip initial emission from loadBookData + .collect { newPageId -> + onPageChange(newPageId) + } + } + + // Sync PageView state to ViewModel for Toolbar rendering + val zoomLevel by page.zoomLevel.collectAsStateWithLifecycle() + val selectionActive = viewModel.selectionState.isNonEmpty() LaunchedEffect( zoomLevel, - page.scroll, - editorState.clipboard, - editorState.isToolbarOpen, - editorState.mode, - editorState.pen, - editorState.eraser, - editorState.penSettings, selectionActive ) { - viewModel.setHasClipboard(editorState.clipboard != null) - viewModel.setShowResetView(zoomLevel != 1.0f) // page.scroll != Offset.Zero + log.v("EditorView: zoomLevel=$zoomLevel, selectionActive=$selectionActive") + viewModel.setShowResetView(zoomLevel != 1.0f) viewModel.setSelectionActive(selectionActive) - viewModel.updateToolbarSettings( - ToolbarUiState( - isToolbarOpen = editorState.isToolbarOpen, - mode = editorState.mode, - pen = editorState.pen, - eraser = editorState.eraser, - penSettings = editorState.penSettings - ) - ) } DisposableEffect(Unit) { onDispose { // finish selection operation - editorState.selectionState.applySelectionDisplace(page) + viewModel.selectionState.applySelectionDisplace(page) if (bookId != null) exportToLinkedFile( exportEngine, bookId, @@ -263,28 +259,27 @@ fun EditorView( } } - // TODO put in editorSetting class + // Persist editor settings when they change LaunchedEffect( - editorState.isToolbarOpen, - editorState.pen, - editorState.penSettings, - editorState.mode, - editorState.eraser + toolbarState.isToolbarOpen, + toolbarState.pen, + toolbarState.penSettings, + toolbarState.mode, + toolbarState.eraser ) { - log.i("EditorView: saving") + log.i("EditorView: saving editor settings") editorSettingCacheManager.setEditorSettings( EditorSettingCacheManager.EditorSettings( - isToolbarOpen = editorState.isToolbarOpen, - mode = editorState.mode, - pen = editorState.pen, - eraser = editorState.eraser, - penSettings = editorState.penSettings + isToolbarOpen = toolbarState.isToolbarOpen, + mode = toolbarState.mode, + pen = toolbarState.pen, + eraser = toolbarState.eraser, + penSettings = toolbarState.penSettings ) ) } - InkaTheme { EditorGestureReceiver(controlTower = editorControlTower) EditorSurface( @@ -299,11 +294,11 @@ fun EditorView( .fillMaxHeight() ) { Spacer(modifier = Modifier.weight(1f)) - ScrollIndicator(state = editorState) + ScrollIndicator(viewModel = viewModel, page = page) } PositionedToolbar( viewModel = viewModel, onDrawingStateCheck = { viewModel.updateDrawingState() }) - HorizontalScrollIndicator(state = editorState) + HorizontalScrollIndicator(viewModel = viewModel, page = page) } } } diff --git a/app/src/main/java/com/ethran/notable/editor/EditorViewModel.kt b/app/src/main/java/com/ethran/notable/editor/EditorViewModel.kt index 72ec1db9..d94bce31 100644 --- a/app/src/main/java/com/ethran/notable/editor/EditorViewModel.kt +++ b/app/src/main/java/com/ethran/notable/editor/EditorViewModel.kt @@ -1,18 +1,22 @@ package com.ethran.notable.editor import android.content.Context +import android.graphics.Color import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ethran.notable.data.AppRepository import com.ethran.notable.data.copyImageToDatabase +import com.ethran.notable.data.datastore.EditorSettingCacheManager import com.ethran.notable.data.datastore.GlobalAppSettings import com.ethran.notable.data.db.getPageIndex import com.ethran.notable.data.db.getParentFolder import com.ethran.notable.data.model.BackgroundType +import com.ethran.notable.editor.EditorViewModel.Companion.DEFAULT_PEN_SETTINGS import com.ethran.notable.editor.canvas.CanvasEventBus import com.ethran.notable.editor.state.Mode +import com.ethran.notable.editor.state.SelectionState import com.ethran.notable.editor.utils.Eraser import com.ethran.notable.editor.utils.Pen import com.ethran.notable.editor.utils.PenSetting @@ -21,25 +25,71 @@ import com.ethran.notable.io.ExportFormat import com.ethran.notable.io.ExportTarget import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import io.shipbook.shipbooksdk.Log import io.shipbook.shipbooksdk.ShipBook import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject - private val log = ShipBook.getLogger("EditorViewModel") +// -------------------------------------------------------- +// 1. UI STATE +// -------------------------------------------------------- /** - * Toolbar Actions (Intents) representing user interactions. + * Flat toolbar/editor UI state exposed to Compose. + * Also used as `EditorUiState` via typealias for backward compatibility. */ +data class ToolbarUiState( + // Document info + val notebookId: String? = null, + val pageId: String? = null, + val isBookActive: Boolean = false, + val pageNumberInfo: String = "1/1", + val currentPageNumber: Int = 0, + + // Background + val backgroundType: String = "native", + val backgroundPath: String = "blank", + val backgroundPageNumber: Int = 0, + + // Toolbar visibility & menus + val isToolbarOpen: Boolean = false, + val isMenuOpen: Boolean = false, + val isStrokeSelectionOpen: Boolean = false, + val isBackgroundSelectorModalOpen: Boolean = false, + val showResetView: Boolean = false, + + // Canvas / drawing + val mode: Mode = Mode.Draw, + val pen: Pen = Pen.BALLPEN, + val eraser: Eraser = Eraser.PEN, + // TODO: if it is an emptyMap(), the DrawCanvas crashes, to be fixed. + val penSettings: Map = DEFAULT_PEN_SETTINGS, + val isSelectionActive: Boolean = false, + val hasClipboard: Boolean = false, + val isDrawing: Boolean = true, + val isQuickNavOpen: Boolean = false, +) { + val isDrawingAllowed: Boolean + get() = !isSelectionActive && + !(isMenuOpen || isStrokeSelectionOpen || isBackgroundSelectorModalOpen) + && !isQuickNavOpen +} + + +// -------------------------------------------------------- +// 2. USER ACTIONS (Intents) +// -------------------------------------------------------- + sealed class ToolbarAction { object ToggleToolbar : ToolbarAction() data class ChangeMode(val mode: Mode) : ToolbarAction() @@ -51,83 +101,55 @@ sealed class ToolbarAction { data class ToggleBackgroundSelector(val isOpen: Boolean) : ToolbarAction() data class ToggleScribbleToErase(val enabled: Boolean) : ToolbarAction() - // Actions that trigger side effects in ControlTower or other components object Undo : ToolbarAction() object Redo : ToolbarAction() object Paste : ToolbarAction() object ResetView : ToolbarAction() object ClearAllStrokes : ToolbarAction() - // Complex Actions data class ImagePicked(val uri: Uri) : ToolbarAction() data class ExportPage(val format: ExportFormat) : ToolbarAction() data class ExportBook(val format: ExportFormat) : ToolbarAction() data class BackgroundChanged(val type: String, val path: String?) : ToolbarAction() - // Navigation object NavigateToLibrary : ToolbarAction() object NavigateToBugReport : ToolbarAction() object NavigateToPages : ToolbarAction() object NavigateToHome : ToolbarAction() object CloseAllMenus : ToolbarAction() + data class UpdateQuickNavOpen(val isOpen: Boolean) : ToolbarAction() } -/** - * UI Events for one-time side effects (navigation, snackbars, etc.) - */ + +// -------------------------------------------------------- +// 3. CANVAS COMMANDS (Imperative drawing actions) +// -------------------------------------------------------- + +sealed class CanvasCommand { + object Undo : CanvasCommand() + object Redo : CanvasCommand() + object Paste : CanvasCommand() + object ResetView : CanvasCommand() + object ClearAllStrokes : CanvasCommand() + object RefreshCanvas : CanvasCommand() + data class CopyImageToCanvas(val uri: Uri) : CanvasCommand() +} + +// -------------------------------------------------------- +// 4. UI EVENTS (Navigation, Snackbars) +// -------------------------------------------------------- + sealed class EditorUiEvent { data class ShowSnackbar(val message: String) : EditorUiEvent() data class NavigateToLibrary(val folderId: String?) : EditorUiEvent() data class NavigateToPages(val bookId: String) : EditorUiEvent() object NavigateToBugReport : EditorUiEvent() - - // Side effects back to drawing logic/engine - object Undo : EditorUiEvent() - object Redo : EditorUiEvent() - object Paste : EditorUiEvent() - object ResetView : EditorUiEvent() - object ClearAllStrokes : EditorUiEvent() - object RefreshCanvas : EditorUiEvent() - data class CopyImageToCanvas(val uri: Uri) : EditorUiEvent() - - // Sync state back to EditorState - data class ModeChanged(val mode: Mode) : EditorUiEvent() - data class PenChanged(val pen: Pen) : EditorUiEvent() - data class PenSettingChanged(val pen: Pen, val setting: PenSetting) : EditorUiEvent() - data class EraserChanged(val eraser: Eraser) : EditorUiEvent() - data class ToolbarVisibilityChanged(val visible: Boolean) : EditorUiEvent() } -/** - * UI State for the Toolbar rendering. - */ -data class ToolbarUiState( - val isToolbarOpen: Boolean = true, - val mode: Mode = Mode.Draw, - val pen: Pen = Pen.BALLPEN, - val penSettings: Map = emptyMap(), - val eraser: Eraser = Eraser.PEN, - val isMenuOpen: Boolean = false, - val isStrokeSelectionOpen: Boolean = false, - val isBackgroundSelectorModalOpen: Boolean = false, - val pageNumberInfo: String = "1/1", - val hasClipboard: Boolean = false, - val showResetView: Boolean = false, - val isSelectionActive: Boolean = false, - - // Context needed for visibility rules in UI - val notebookId: String? = null, - val pageId: String? = null, - val isBookActive: Boolean = false, - - // TODO: check correctness - // Internal data for BackgroundSelector rendering if it remains stateless - val backgroundType: String = "native", - val backgroundPath: String = "blank", - val backgroundPageNumber: Int = 0, - val currentPageNumber: Int = 0 -) +// -------------------------------------------------------- +// 5. VIEW MODEL +// -------------------------------------------------------- @HiltViewModel class EditorViewModel @Inject constructor( @@ -136,29 +158,66 @@ class EditorViewModel @Inject constructor( private val exportEngine: ExportEngine ) : ViewModel() { + // ---- Toolbar / UI State (single flat flow) ---- private val _toolbarState = MutableStateFlow(ToolbarUiState()) val toolbarState: StateFlow = _toolbarState.asStateFlow() - private val _uiEvents = MutableSharedFlow() - val uiEvents: SharedFlow = _uiEvents.asSharedFlow() + // ---- One-Time Events (Channels) ---- + private val uiEventChannel = Channel(Channel.BUFFERED) + val uiEvents = uiEventChannel.receiveAsFlow() + + private val canvasCommandChannel = Channel(Channel.BUFFERED) + val canvasCommands = canvasCommandChannel.receiveAsFlow() + + // ---- Internal document context ---- + private var bookId: String? = null + private val currentPageId: String get() = _toolbarState.value.pageId.orEmpty() + + // ---- Init guard ---- + private val didInitSettings = AtomicBoolean(false) + + // ---- Selection state (kept for drawing logic compatibility) ---- + val selectionState = SelectionState() + + // -------------------------------------------------------- + // Initialization from persisted settings + // -------------------------------------------------------- + + /** + * Restores editor settings from the persisted cache. + * Idempotent: only applies settings on first call; subsequent calls are no-ops. + */ + fun initFromPersistedSettings(settings: EditorSettingCacheManager.EditorSettings?) { + if (!didInitSettings.compareAndSet(false, true)) return + + _toolbarState.update { + it.copy( + mode = settings?.mode ?: Mode.Draw, + pen = settings?.pen ?: Pen.BALLPEN, + eraser = settings?.eraser ?: Eraser.PEN, + isToolbarOpen = settings?.isToolbarOpen ?: false, + penSettings = settings?.penSettings ?: DEFAULT_PEN_SETTINGS + ) + } + } - // Internal context - private var currentBookId: String? = null - private var currentPageId: String = "" + // -------------------------------------------------------- + // Toolbar Action Dispatch + // -------------------------------------------------------- fun onToolbarAction(action: ToolbarAction) { + log.v("onToolbarAction: $action") when (action) { is ToolbarAction.ToggleToolbar -> { - val newVisible = !_toolbarState.value.isToolbarOpen - _toolbarState.update { it.copy(isToolbarOpen = newVisible) } - sendUiEvent(EditorUiEvent.ToolbarVisibilityChanged(newVisible)) + _toolbarState.update { it.copy(isToolbarOpen = !it.isToolbarOpen) } updateDrawingState() } + is ToolbarAction.ChangeMode -> { _toolbarState.update { it.copy(mode = action.mode) } - sendUiEvent(EditorUiEvent.ModeChanged(action.mode)) updateDrawingState() } + is ToolbarAction.ChangePen -> handlePenChange(action.pen) is ToolbarAction.ChangePenSetting -> handlePenSettingChange(action.pen, action.setting) is ToolbarAction.ChangeEraser -> handleEraserChange(action.eraser) @@ -166,10 +225,12 @@ class EditorViewModel @Inject constructor( _toolbarState.update { it.copy(isMenuOpen = !it.isMenuOpen) } updateDrawingState() } + is ToolbarAction.UpdateMenuOpenTo -> { _toolbarState.update { it.copy(isStrokeSelectionOpen = action.isOpen) } updateDrawingState() } + is ToolbarAction.ToggleBackgroundSelector -> { _toolbarState.update { it.copy(isBackgroundSelectorModalOpen = action.isOpen) } updateDrawingState() @@ -177,17 +238,22 @@ class EditorViewModel @Inject constructor( is ToolbarAction.ToggleScribbleToErase -> updateScribbleToErase(action.enabled) is ToolbarAction.ImagePicked -> handleImagePicked(action.uri) - is ToolbarAction.ExportPage -> handleExport(ExportTarget.Page(currentPageId), action.format) + is ToolbarAction.ExportPage -> handleExport( + ExportTarget.Page(currentPageId), + action.format + ) + is ToolbarAction.ExportBook -> { - currentBookId?.let { handleExport(ExportTarget.Book(it), action.format) } + bookId?.let { handleExport(ExportTarget.Book(it), action.format) } } + is ToolbarAction.BackgroundChanged -> handleBackgroundChange(action.type, action.path) - ToolbarAction.Undo -> sendUiEvent(EditorUiEvent.Undo) - ToolbarAction.Redo -> sendUiEvent(EditorUiEvent.Redo) - ToolbarAction.Paste -> sendUiEvent(EditorUiEvent.Paste) - ToolbarAction.ResetView -> sendUiEvent(EditorUiEvent.ResetView) - ToolbarAction.ClearAllStrokes -> sendUiEvent(EditorUiEvent.ClearAllStrokes) + ToolbarAction.Undo -> sendCanvasCommand(CanvasCommand.Undo) + ToolbarAction.Redo -> sendCanvasCommand(CanvasCommand.Redo) + ToolbarAction.Paste -> sendCanvasCommand(CanvasCommand.Paste) + ToolbarAction.ResetView -> sendCanvasCommand(CanvasCommand.ResetView) + ToolbarAction.ClearAllStrokes -> sendCanvasCommand(CanvasCommand.ClearAllStrokes) ToolbarAction.NavigateToLibrary -> handleNavigateToLibrary() ToolbarAction.NavigateToBugReport -> sendUiEvent(EditorUiEvent.NavigateToBugReport) @@ -195,41 +261,39 @@ class EditorViewModel @Inject constructor( ToolbarAction.NavigateToHome -> sendUiEvent(EditorUiEvent.NavigateToLibrary(null)) ToolbarAction.CloseAllMenus -> handleCloseAllMenus() + is ToolbarAction.UpdateQuickNavOpen -> { + _toolbarState.update { it.copy(isQuickNavOpen = action.isOpen) } + updateDrawingState() + } } } - private fun sendUiEvent(event: EditorUiEvent) { - viewModelScope.launch { _uiEvents.emit(event) } - } + // -------------------------------------------------------- + // Toolbar Action Handlers (private) + // -------------------------------------------------------- private fun handlePenChange(pen: Pen) { - var penChanged = false - var modeChanged = false - - _toolbarState.update { state -> - if (state.mode == Mode.Draw && state.pen == pen) { - state.copy(isStrokeSelectionOpen = true) - } else { - penChanged = true - if (state.mode != Mode.Draw) { - modeChanged = true - } - state.copy(mode = Mode.Draw, pen = pen) + val state = _toolbarState.value + if (state.mode == Mode.Draw && state.pen == pen) { + _toolbarState.update { it.copy(isStrokeSelectionOpen = true) } + } else { + _toolbarState.update { + it.copy(pen = pen, mode = Mode.Draw) } } - // Fire side-effects outside the update block - if (penChanged) sendUiEvent(EditorUiEvent.PenChanged(pen)) - if (modeChanged) sendUiEvent(EditorUiEvent.ModeChanged(Mode.Draw)) - updateDrawingState() } private fun handleEraserChange(eraser: Eraser) { _toolbarState.update { it.copy(eraser = eraser) } - sendUiEvent(EditorUiEvent.EraserChanged(eraser)) updateDrawingState() } + private fun handlePenSettingChange(pen: Pen, setting: PenSetting) { + val newSettings = _toolbarState.value.penSettings.toMutableMap() + newSettings[pen.penName] = setting + _toolbarState.update { it.copy(penSettings = newSettings) } + } private fun handleCloseAllMenus() { log.d("Closing all menus in EditorViewModel") @@ -243,36 +307,6 @@ class EditorViewModel @Inject constructor( updateDrawingState() } - /** - * Re-evaluates whether drawing should be enabled based on menu and selection states. - * Also handles switching back to drawing mode when menus are closed. - */ - fun updateDrawingState() { - val state = _toolbarState.value - val anyMenuOpen = - state.isMenuOpen || state.isStrokeSelectionOpen || state.isBackgroundSelectorModalOpen - val shouldBeDrawing = !anyMenuOpen && !_toolbarState.value.isSelectionActive - log.d("Drawing state: $shouldBeDrawing") - viewModelScope.launch { - CanvasEventBus.isDrawing.emit(shouldBeDrawing) - } - } - - fun onFocusChanged(isFocused: Boolean) { - if (isFocused) { - updateDrawingState() - } - } - - private fun handlePenSettingChange(pen: Pen, setting: PenSetting) { - _toolbarState.update { state -> - val newSettings = state.penSettings.toMutableMap() - newSettings[pen.penName] = setting - state.copy(penSettings = newSettings) - } - sendUiEvent(EditorUiEvent.PenSettingChanged(pen, setting)) - } - private fun updateScribbleToErase(enabled: Boolean) { viewModelScope.launch(Dispatchers.IO) { appRepository.kvProxy.setAppSettings( @@ -285,9 +319,9 @@ class EditorViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { try { val copiedFile = copyImageToDatabase(context, uri) - _uiEvents.emit(EditorUiEvent.CopyImageToCanvas(copiedFile.toUri())) + sendCanvasCommand(CanvasCommand.CopyImageToCanvas(copiedFile.toUri())) } catch (e: Exception) { - _uiEvents.emit(EditorUiEvent.ShowSnackbar("Image import failed: ${e.message}")) + sendUiEvent(EditorUiEvent.ShowSnackbar("Image import failed: ${e.message}")) } } } @@ -296,9 +330,9 @@ class EditorViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { try { val result = exportEngine.export(target, format) - _uiEvents.emit(EditorUiEvent.ShowSnackbar(result)) + sendUiEvent(EditorUiEvent.ShowSnackbar(result)) } catch (e: Exception) { - _uiEvents.emit(EditorUiEvent.ShowSnackbar("Export failed: ${e.message}")) + sendUiEvent(EditorUiEvent.ShowSnackbar("Export failed: ${e.message}")) } } } @@ -317,20 +351,20 @@ class EditorViewModel @Inject constructor( val bgPageNum = when (val bgTypeObj = BackgroundType.fromKey(type)) { is BackgroundType.Pdf -> bgTypeObj.page is BackgroundType.AutoPdf -> { - currentBookId?.let { appRepository.getPageNumber(it, currentPageId) } ?: 0 + bookId?.let { appRepository.getPageNumber(it, currentPageId) } ?: 0 } else -> 0 } - _toolbarState.update { + _toolbarState.update { it.copy( backgroundType = updatedPage.backgroundType, backgroundPath = updatedPage.background, backgroundPageNumber = bgPageNum ) } - _uiEvents.emit(EditorUiEvent.RefreshCanvas) + sendCanvasCommand(CanvasCommand.RefreshCanvas) } } @@ -338,29 +372,54 @@ class EditorViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { val page = appRepository.pageRepository.getById(currentPageId) val parentFolder = page?.getParentFolder(appRepository.bookRepository) - _uiEvents.emit(EditorUiEvent.NavigateToLibrary(parentFolder)) + sendUiEvent(EditorUiEvent.NavigateToLibrary(parentFolder)) } } private fun handleNavigateToPages() { - currentBookId?.let { bookId -> - viewModelScope.launch { - _uiEvents.emit(EditorUiEvent.NavigateToPages(bookId)) - } + bookId?.let { id -> + sendUiEvent(EditorUiEvent.NavigateToPages(id)) } } + // -------------------------------------------------------- + // Drawing State + // -------------------------------------------------------- + + /** + * Re-evaluates whether drawing should be enabled based on menu and selection states. + */ + fun updateDrawingState() { + log.v("updateDrawingState") + val shouldBeDrawing = _toolbarState.value.isDrawingAllowed + _toolbarState.update { it.copy(isDrawing = shouldBeDrawing) } + log.d("Drawing state: $shouldBeDrawing") + viewModelScope.launch { + CanvasEventBus.isDrawing.emit(shouldBeDrawing) + } + } + + fun onFocusChanged(isFocused: Boolean) { + if (isFocused) { + updateDrawingState() + } + } + + // -------------------------------------------------------- + // Book / Page Data + // -------------------------------------------------------- + /** * Loads context data for the toolbar (page number, background info, etc.) */ fun loadBookData(bookId: String?, pageId: String) { - currentBookId = bookId - currentPageId = pageId + log.v("loadBookData: bookId=$bookId, pageId=$pageId") + this.bookId = bookId viewModelScope.launch(Dispatchers.IO) { val page = appRepository.pageRepository.getById(pageId) val book = bookId?.let { appRepository.bookRepository.getById(it) } - + val pageIndex = book?.getPageIndex(pageId) ?: 0 val totalPages = book?.pageIds?.size ?: 1 @@ -373,7 +432,7 @@ class EditorViewModel @Inject constructor( else -> 0 } - + _toolbarState.update { it.copy( notebookId = bookId, @@ -389,6 +448,73 @@ class EditorViewModel @Inject constructor( } } + // -------------------------------------------------------- + // Page Navigation (from EditorState) + // -------------------------------------------------------- + + private suspend fun getNextPageId(): String? { + return if (bookId != null) { + appRepository.getNextPageIdFromBookAndPageOrCreate( + pageId = currentPageId, notebookId = bookId!! + ) + } else null + } + + private suspend fun getPreviousPageId(): String? { + return if (bookId != null) { + appRepository.getPreviousPageIdFromBookAndPage( + pageId = currentPageId, notebookId = bookId!! + ) + } else null + } + + fun goToNextPage() { + log.v("goToNextPage") + viewModelScope.launch(Dispatchers.IO) { + getNextPageId()?.let { changePage(it) } + } + } + + fun goToPreviousPage() { + log.v("goToPreviousPage") + viewModelScope.launch(Dispatchers.IO) { + getPreviousPageId()?.let { changePage(it) } + } + } + + private suspend fun updateOpenedPage(newPageId: String) { + log.v("updateOpenedPage: $newPageId") + Log.d("EditorView", "Update open page to $newPageId") + if (bookId != null) { + appRepository.bookRepository.setOpenPageId(bookId!!, newPageId) + } + if (newPageId != currentPageId) { + Log.d("EditorView", "Page changed") + loadBookData(bookId, newPageId) + } else { + Log.d("EditorView", "Tried to change to same page!") + sendUiEvent(EditorUiEvent.ShowSnackbar("Tried to change to same page!")) + } + } + + /** + * Changes the current page to the one with the specified [id]. + * + * @param id The unique identifier of the page to switch to. + */ + fun changePage(id: String) { + log.v("changePage: $id") + log.d("Changing page to $id, from $currentPageId") + viewModelScope.launch(Dispatchers.IO) { + updateOpenedPage(id) + selectionState.reset() + } + } + + // -------------------------------------------------------- + // Toolbar State Sync Helpers + // -------------------------------------------------------- + fun setHasClipboard(hasClipboard: Boolean) { _toolbarState.update { it.copy(hasClipboard = hasClipboard) } } @@ -398,21 +524,42 @@ class EditorViewModel @Inject constructor( } fun setSelectionActive(active: Boolean) { + log.v("setSelectionActive: $active") if (_toolbarState.value.isSelectionActive != active) { + if (active) //selection is active, we can directly update it, and skip other checks + viewModelScope.launch { + CanvasEventBus.isDrawing.emit(false) + } _toolbarState.update { it.copy(isSelectionActive = active) } - updateDrawingState() + if (!active) + updateDrawingState() } } - fun updateToolbarSettings(state: ToolbarUiState) { - _toolbarState.update { - it.copy( - isToolbarOpen = state.isToolbarOpen, - mode = state.mode, - pen = state.pen, - eraser = state.eraser, - penSettings = state.penSettings - ) - } + // -------------------------------------------------------- + // Event / Command Helpers + // -------------------------------------------------------- + + private fun sendUiEvent(event: EditorUiEvent) { + log.v("sendUiEvent: $event") + viewModelScope.launch { uiEventChannel.send(event) } } -} + + private fun sendCanvasCommand(command: CanvasCommand) { + log.v("sendCanvasCommand: $command") + viewModelScope.launch { canvasCommandChannel.send(command) } + } + + companion object { + val DEFAULT_PEN_SETTINGS = mapOf( + Pen.BALLPEN.penName to PenSetting(5f, Color.BLACK), + Pen.REDBALLPEN.penName to PenSetting(5f, Color.RED), + Pen.BLUEBALLPEN.penName to PenSetting(5f, Color.BLUE), + Pen.GREENBALLPEN.penName to PenSetting(5f, Color.GREEN), + Pen.PENCIL.penName to PenSetting(5f, Color.BLACK), + Pen.BRUSH.penName to PenSetting(5f, Color.BLACK), + Pen.MARKER.penName to PenSetting(40f, Color.LTGRAY), + Pen.FOUNTAIN.penName to PenSetting(5f, Color.BLACK) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt index b541c793..a19af78b 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasObserverRegistry.kt @@ -173,7 +173,7 @@ class CanvasObserverRegistry( private fun observePenChanges() { coroutineScope.launch { - snapshotFlow { state.pen }.drop(1).collect { + snapshotFlow { state.pen }.drop(0).collect { logCanvasObserver.v("pen change: ${state.pen}") inputHandler.updatePenAndStroke() refreshManager.refreshUiSuspend() @@ -298,5 +298,3 @@ class CanvasObserverRegistry( } - - diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt index c5a4f2d4..88260440 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt @@ -6,10 +6,10 @@ import android.graphics.Paint import android.graphics.Rect import android.os.Looper import com.ethran.notable.data.model.SimplePointF +import com.ethran.notable.editor.state.Mode import com.ethran.notable.editor.PageView import com.ethran.notable.editor.drawing.selectPaint import com.ethran.notable.editor.state.EditorState -import com.ethran.notable.editor.state.Mode import com.ethran.notable.editor.utils.pointsToPath import com.ethran.notable.editor.utils.refreshScreenRegion import com.ethran.notable.editor.utils.resetScreenFreeze diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt index d816d3b1..75cae6ab 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt @@ -11,12 +11,12 @@ import android.view.SurfaceHolder import android.view.SurfaceView import com.ethran.notable.data.AppRepository import com.ethran.notable.data.model.SimplePointF +import com.ethran.notable.editor.state.Mode import com.ethran.notable.editor.PageView import com.ethran.notable.editor.drawing.OpenGLRenderer import com.ethran.notable.editor.drawing.selectPaint import com.ethran.notable.editor.state.EditorState import com.ethran.notable.editor.state.History -import com.ethran.notable.editor.state.Mode import com.ethran.notable.editor.state.Operation import com.ethran.notable.editor.utils.DeviceCompat import com.ethran.notable.editor.utils.onSurfaceChanged @@ -108,8 +108,8 @@ class DrawCanvas( log.i("surface created $holder") // set up the drawing surface inputHandler.updateActiveSurface() - // Restore the correct stroke size and style. - inputHandler.updatePenAndStroke() + // Restore the correct stroke size and style, now its done in initFromPersistedSettings +// inputHandler.updatePenAndStroke() } override fun surfaceChanged( diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt index c07f6d4c..323463fb 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt @@ -7,10 +7,10 @@ import android.util.Log import androidx.compose.ui.unit.dp import androidx.core.graphics.toRect import com.ethran.notable.data.datastore.GlobalAppSettings +import com.ethran.notable.editor.state.Mode import com.ethran.notable.editor.PageView import com.ethran.notable.editor.state.EditorState import com.ethran.notable.editor.state.History -import com.ethran.notable.editor.state.Mode import com.ethran.notable.editor.utils.DeviceCompat import com.ethran.notable.editor.utils.Eraser import com.ethran.notable.editor.utils.Pen diff --git a/app/src/main/java/com/ethran/notable/editor/state/EditorState.kt b/app/src/main/java/com/ethran/notable/editor/state/EditorState.kt index 0f271951..fc632834 100644 --- a/app/src/main/java/com/ethran/notable/editor/state/EditorState.kt +++ b/app/src/main/java/com/ethran/notable/editor/state/EditorState.kt @@ -1,138 +1,74 @@ package com.ethran.notable.editor.state -import android.graphics.Color import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import com.ethran.notable.data.AppRepository -import com.ethran.notable.data.datastore.EditorSettingCacheManager -import com.ethran.notable.editor.PageView +import com.ethran.notable.editor.EditorViewModel +import com.ethran.notable.editor.ToolbarUiState import com.ethran.notable.editor.utils.Eraser import com.ethran.notable.editor.utils.Pen import com.ethran.notable.editor.utils.PenSetting -import com.ethran.notable.ui.SnackConf -import com.ethran.notable.ui.SnackState -import io.shipbook.shipbooksdk.Log -import io.shipbook.shipbooksdk.ShipBook -enum class Mode { - Draw, Erase, Select, Line -} - - // TODO: move to EditorViewModel, or somewhere else, this code shouldnt be here. +/** + * Wrapper around EditorViewModel for backward compatibility with canvas components + * (DrawCanvas, OnyxInputHandler, CanvasRefreshManager, etc. that still expect EditorState). + * + * Holds its own Compose snapshot state (mutableStateOf) so that canvas observers using + * snapshotFlow remain reactive. Call [syncFrom] whenever [EditorViewModel.toolbarState] changes. + */ +@Stable class EditorState( - val bookId: String? = null, - val pageId: String, - val pageView: PageView, - val appRepository: AppRepository, - persistedEditorSettings: EditorSettingCacheManager.EditorSettings?, - val onPageChange: (String) -> Unit + val viewModel: EditorViewModel, ) { - var currentPageId by mutableStateOf(pageId) - private set - - - suspend fun getNextPage(): String? { - return if (bookId != null) { - val newPageId = appRepository.getNextPageIdFromBookAndPageOrCreate( - pageId = currentPageId, notebookId = bookId - ) - newPageId - } else null - } - - suspend fun getPreviousPage(): String? { - return if (bookId != null) { - val newPageId = appRepository.getPreviousPageIdFromBookAndPage( - pageId = currentPageId, notebookId = bookId - ) - newPageId - } else null - } + private val initial = viewModel.toolbarState.value + var mode by mutableStateOf(initial.mode) + private set - suspend fun updateOpenedPage(newPageId: String) { - Log.d("EditorView", "Update open page to $newPageId") - if (bookId != null) { - appRepository.bookRepository.setOpenPageId(bookId, newPageId) - } - if (newPageId != currentPageId) { - Log.d("EditorView", "Page changed") - onPageChange(newPageId) - currentPageId = newPageId - } else { - Log.d("EditorView", "Tried to change to same page!") - SnackState.globalSnackFlow.tryEmit( - SnackConf(text = "Tried to change to same page!", duration = 3000) - ) - } - } + var pen by mutableStateOf(initial.pen) + private set + var eraser by mutableStateOf(initial.eraser) + private set - private val log = ShipBook.getLogger("EditorState") + var penSettings by mutableStateOf(initial.penSettings) + private set - var mode by mutableStateOf(persistedEditorSettings?.mode ?: Mode.Draw) // should save - var pen by mutableStateOf(persistedEditorSettings?.pen ?: Pen.BALLPEN) // should save - var eraser by mutableStateOf(persistedEditorSettings?.eraser ?: Eraser.PEN) // should save - var isDrawing by mutableStateOf(true) // gives information if pen touch will be drawn or not - // For debugging: -// var isDrawing: Boolean -// get() = _isDrawing -// set(value) { -// if (_isDrawing != value) { -// Log.d(TAG, "isDrawing modified from ${_isDrawing} to $value") -// logCallStack("isDrawing modification") -// _isDrawing = value -// } -// } + var isToolbarOpen by mutableStateOf(initial.isToolbarOpen) + private set - var isToolbarOpen by mutableStateOf( - persistedEditorSettings?.isToolbarOpen ?: false - ) // should save - var penSettings by mutableStateOf( - persistedEditorSettings?.penSettings ?: mapOf( - Pen.BALLPEN.penName to PenSetting(5f, Color.BLACK), - Pen.REDBALLPEN.penName to PenSetting(5f, Color.RED), - Pen.BLUEBALLPEN.penName to PenSetting(5f, Color.BLUE), - Pen.GREENBALLPEN.penName to PenSetting(5f, Color.GREEN), - Pen.PENCIL.penName to PenSetting(5f, Color.BLACK), - Pen.BRUSH.penName to PenSetting(5f, Color.BLACK), - Pen.MARKER.penName to PenSetting(40f, Color.LTGRAY), - Pen.FOUNTAIN.penName to PenSetting(5f, Color.BLACK) - ) - ) + // Intentionally public — canvas components (CanvasObserverRegistry, EditorControlTower, + // Select) set this directly, and CanvasObserverRegistry uses snapshotFlow { state.isDrawing }. + var isDrawing by mutableStateOf(initial.isDrawing) - val selectionState = SelectionState() + val selectionState: SelectionState + get() = viewModel.selectionState private var _clipboard by mutableStateOf(Clipboard.content) - var clipboard + var clipboard: ClipboardContent? get() = _clipboard set(value) { - this._clipboard = value + _clipboard = value // The clipboard content must survive the EditorState, so we store a copy in // a singleton that lives outside of the EditorState Clipboard.content = value + viewModel.setHasClipboard(value != null) } - + init { + viewModel.setHasClipboard(_clipboard != null) + } /** - * Changes the current page to the one with the specified [id]. - * - * @param id The unique identifier of the page to switch to. + * Synchronises this EditorState's mutableStateOf fields from the given [ToolbarUiState]. + * Call this from a LaunchedEffect in EditorView whenever toolbarState changes. */ - suspend fun changePage(id: String) { - log.d("Changing page to $id, from $currentPageId") - updateOpenedPage(id) - selectionState.reset() + fun syncFrom(state: ToolbarUiState) { + mode = state.mode + pen = state.pen + eraser = state.eraser + penSettings = state.penSettings + isToolbarOpen = state.isToolbarOpen + isDrawing = state.isDrawing } } - -// if state is Move then applySelectionDisplace() will delete original strokes and images -enum class PlacementMode { - Move, Paste -} - -object Clipboard { - var content: ClipboardContent? = null -} \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/editor/state/EditorTypes.kt b/app/src/main/java/com/ethran/notable/editor/state/EditorTypes.kt new file mode 100644 index 00000000..21ea22d2 --- /dev/null +++ b/app/src/main/java/com/ethran/notable/editor/state/EditorTypes.kt @@ -0,0 +1,23 @@ +package com.ethran.notable.editor.state + +/** + * Drawing mode for the editor. + */ +enum class Mode { + Draw, Erase, Select, Line +} + +/** + * Placement mode for selection operations. + * If state is Move then applySelectionDisplace() will delete original strokes and images. + */ +enum class PlacementMode { + Move, Paste +} + +/** + * Singleton holding clipboard content across EditorState instances. + */ +object Clipboard { + var content: ClipboardContent? = null +} diff --git a/app/src/main/java/com/ethran/notable/editor/state/SelectionState.kt b/app/src/main/java/com/ethran/notable/editor/state/SelectionState.kt index 1500e07d..e95c7299 100644 --- a/app/src/main/java/com/ethran/notable/editor/state/SelectionState.kt +++ b/app/src/main/java/com/ethran/notable/editor/state/SelectionState.kt @@ -45,6 +45,7 @@ class SelectionState { var placementMode by mutableStateOf(null) fun reset() { + log.v("reset") selectedStrokes = null selectedImages = null secondPageCut = null @@ -66,6 +67,7 @@ class SelectionState { } fun resizeImages(scale: Int, scope: CoroutineScope, page: PageView) { + log.v("resizeImages: scale=$scale") val selectedImagesCopy = selectedImages?.map { image -> image.copy( height = image.height + (image.height * scale / 100), @@ -112,6 +114,7 @@ class SelectionState { @Suppress("UNUSED_PARAMETER") fun resizeStrokes(scale: Int, scope: CoroutineScope, page: PageView) { + log.v("resizeStrokes: scale=$scale") //TODO: implement this } @@ -125,6 +128,7 @@ class SelectionState { * @return A list of [Operation]s that can be used to undo the deletion (e.g., re-adding the deleted items). */ fun deleteSelection(page: PageView): List { + log.v("deleteSelection: images=${selectedImages?.size}, strokes=${selectedStrokes?.size}") val operationList = mutableListOf() val selectedImagesToRemove = selectedImages if (!selectedImagesToRemove.isNullOrEmpty()) { @@ -145,6 +149,7 @@ class SelectionState { } fun duplicateSelection() { + log.v("duplicateSelection") // set operation to paste only placementMode = PlacementMode.Paste if (!selectedStrokes.isNullOrEmpty()) @@ -168,13 +173,14 @@ class SelectionState { } // move the selection a bit, to show the copy selectionDisplaceOffset = IntOffset( - x = selectionDisplaceOffset!!.x + 50, - y = selectionDisplaceOffset!!.y + 50, + x = (selectionDisplaceOffset?.x ?: 0) + 50, + y = (selectionDisplaceOffset?.y ?: 0) + 50, ) } // Moves strokes, and redraws canvas. fun applySelectionDisplace(page: PageView): List? { + log.v("applySelectionDisplace: offset=$selectionDisplaceOffset, mode=$placementMode") if (selectionDisplaceOffset == null) return null if (selectionRect == null) return null @@ -234,6 +240,7 @@ class SelectionState { } fun selectionToClipboard(scrollPos: Offset, context: Context): ClipboardContent { + log.v("selectionToClipboard: scrollPos=$scrollPos, images=${selectedImages?.size}, strokes=${selectedStrokes?.size}") val strokes = selectedStrokes?.map { offsetStroke(it, offset = -scrollPos) @@ -251,4 +258,4 @@ class SelectionState { images = images ?: emptyList() ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ethran/notable/editor/ui/EditorSurface.kt b/app/src/main/java/com/ethran/notable/editor/ui/EditorSurface.kt index bce55452..66b4211b 100644 --- a/app/src/main/java/com/ethran/notable/editor/ui/EditorSurface.kt +++ b/app/src/main/java/com/ethran/notable/editor/ui/EditorSurface.kt @@ -17,7 +17,9 @@ private val log = ShipBook.getLogger("EditorSurface") @Composable fun EditorSurface( appRepository: AppRepository, - state: EditorState, page: PageView, history: History + state: EditorState, + page: PageView, + history: History ) { val coroutineScope = rememberCoroutineScope() log.i("recompose surface") @@ -27,8 +29,10 @@ fun EditorSurface( DrawCanvas( context = ctx, appRepository = appRepository, - state = state, page = page, history = history, - coroutineScope = coroutineScope + coroutineScope = coroutineScope, + state = state, + page = page, + history = history ).apply { init() registerObservers() @@ -36,4 +40,4 @@ fun EditorSurface( }, modifier = Modifier.fillMaxSize() ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ethran/notable/editor/ui/ScrollIndicator.kt b/app/src/main/java/com/ethran/notable/editor/ui/ScrollIndicator.kt index e27d3f4f..9a83c6f2 100644 --- a/app/src/main/java/com/ethran/notable/editor/ui/ScrollIndicator.kt +++ b/app/src/main/java/com/ethran/notable/editor/ui/ScrollIndicator.kt @@ -12,11 +12,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import com.ethran.notable.editor.state.EditorState +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ethran.notable.editor.EditorViewModel +import com.ethran.notable.editor.PageView import com.ethran.notable.ui.convertDpToPixel import kotlin.math.max @@ -25,14 +28,14 @@ import kotlin.math.max * Uses page.scroll.y (IntOffset) for the vertical position. */ @Composable -fun ScrollIndicator(state: EditorState) { +fun ScrollIndicator(viewModel: EditorViewModel, page: PageView) { + val toolbarState by viewModel.toolbarState.collectAsStateWithLifecycle() BoxWithConstraints( modifier = Modifier .width(5.dp) .fillMaxHeight() ) { val viewportHeightPx = convertDpToPixel(this.maxHeight, LocalContext.current).toInt() - val page = state.pageView // Total scrollable height approximation: // page.height is the total content height (page coordinates) @@ -43,7 +46,7 @@ fun ScrollIndicator(state: EditorState) { val indicatorSizeDp = (viewportHeightPx / virtualHeight.toFloat()) * this.maxHeight.value val indicatorPositionDp = (page.scroll.y / virtualHeight.toFloat()) * this.maxHeight.value - if (!state.isToolbarOpen) return@BoxWithConstraints + if (!toolbarState.isToolbarOpen) return@BoxWithConstraints Box( modifier = Modifier @@ -62,7 +65,8 @@ fun ScrollIndicator(state: EditorState) { * Uses page.scroll.x (IntOffset) for the horizontal position. */ @Composable -fun HorizontalScrollIndicator(state: EditorState) { +fun HorizontalScrollIndicator(viewModel: EditorViewModel, page: PageView) { + val toolbarState by viewModel.toolbarState.collectAsStateWithLifecycle() Column(Modifier.fillMaxSize()) { Spacer(Modifier.weight(1f)) BoxWithConstraints( @@ -71,7 +75,6 @@ fun HorizontalScrollIndicator(state: EditorState) { .fillMaxWidth() ) { val viewportWidthPx = convertDpToPixel(this.maxWidth, LocalContext.current).toInt() - val page = state.pageView // Total scrollable width approximation: // page.width is the total content width (page coordinates) @@ -82,7 +85,7 @@ fun HorizontalScrollIndicator(state: EditorState) { val indicatorSizeDp = (viewportWidthPx / virtualWidth.toFloat()) * this.maxWidth.value val indicatorPositionDp = (page.scroll.x / virtualWidth.toFloat()) * this.maxWidth.value - if (!state.isToolbarOpen) return@BoxWithConstraints + if (!toolbarState.isToolbarOpen) return@BoxWithConstraints Box( modifier = Modifier @@ -95,4 +98,4 @@ fun HorizontalScrollIndicator(state: EditorState) { ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ethran/notable/editor/ui/toolbar/ToolbarMenu.kt b/app/src/main/java/com/ethran/notable/editor/ui/toolbar/ToolbarMenu.kt index 0954299d..8fd67021 100644 --- a/app/src/main/java/com/ethran/notable/editor/ui/toolbar/ToolbarMenu.kt +++ b/app/src/main/java/com/ethran/notable/editor/ui/toolbar/ToolbarMenu.kt @@ -25,9 +25,9 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import com.ethran.notable.R import com.ethran.notable.data.datastore.BUTTON_SIZE +import com.ethran.notable.editor.state.Mode import com.ethran.notable.editor.ToolbarAction import com.ethran.notable.editor.ToolbarUiState -import com.ethran.notable.editor.state.Mode import com.ethran.notable.editor.utils.Pen import com.ethran.notable.editor.utils.PenSetting import com.ethran.notable.io.ExportFormat diff --git a/app/src/main/java/com/ethran/notable/editor/utils/ImageHandler.kt b/app/src/main/java/com/ethran/notable/editor/utils/ImageHandler.kt index df653391..f60ab72d 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/ImageHandler.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/ImageHandler.kt @@ -7,10 +7,10 @@ import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.asImageBitmap import com.ethran.notable.data.db.Image import com.ethran.notable.editor.PageView +import com.ethran.notable.editor.state.PlacementMode import com.ethran.notable.editor.canvas.CanvasEventBus import com.ethran.notable.editor.drawing.drawImage import com.ethran.notable.editor.state.EditorState -import com.ethran.notable.editor.state.PlacementMode import com.ethran.notable.io.uriToBitmap import com.ethran.notable.ui.showHint import io.shipbook.shipbooksdk.Log diff --git a/app/src/main/java/com/ethran/notable/editor/utils/Select.kt b/app/src/main/java/com/ethran/notable/editor/utils/Select.kt index 74836f7e..8448623a 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/Select.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/Select.kt @@ -11,8 +11,8 @@ import com.ethran.notable.data.PageDataManager import com.ethran.notable.data.db.Image import com.ethran.notable.data.db.Stroke import com.ethran.notable.data.model.SimplePointF -import com.ethran.notable.editor.canvas.CanvasEventBus import com.ethran.notable.editor.PageView +import com.ethran.notable.editor.canvas.CanvasEventBus import com.ethran.notable.editor.drawing.drawImage import com.ethran.notable.editor.drawing.drawStroke import com.ethran.notable.editor.state.EditorState @@ -45,7 +45,14 @@ fun selectStrokesFromPath(strokes: List, path: Path): List { return strokes.filter { strokeBounds(it).intersect(bounds) - }.filter { it.points.any { region.contains(it.x.toInt(), (it.y - bounds.top).toInt()) } } + }.filter { + it.points.any { point -> + region.contains( + point.x.toInt(), + (point.y - bounds.top).toInt() + ) + } + } } fun selectImagesFromPath(images: List, path: Path): List { @@ -61,7 +68,7 @@ fun selectImagesFromPath(images: List, path: Path): List { imageBounds(it).intersect(bounds) }.filter { // include image if all its corners are within region - imagePoints(it).all { region.contains(it.x, (it.y - bounds.top).toInt()) } + imagePoints(it).all { point -> region.contains(point.x, (point.y - bounds.top).toInt()) } } } @@ -74,6 +81,7 @@ fun selectImagesAndStrokes( imagesToSelect: List, strokesToSelect: List ) { + log.v("selectImagesAndStrokes: images=${imagesToSelect.size}, strokes=${strokesToSelect.size}") //handle selection: val pageBounds = Rect() diff --git a/app/src/main/java/com/ethran/notable/navigation/NotableNavHost.kt b/app/src/main/java/com/ethran/notable/navigation/NotableNavHost.kt index 5f955df1..be732c5d 100644 --- a/app/src/main/java/com/ethran/notable/navigation/NotableNavHost.kt +++ b/app/src/main/java/com/ethran/notable/navigation/NotableNavHost.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -106,6 +105,7 @@ fun NotableNavHost( navController = appNavigator.navController, bookId = bookId, pageId = currentPageId, + isQuickNavOpen = appNavigator.isQuickNavOpen, onPageChange = { newPageId -> appNavigator.onPageChange( backStackEntry,