Skip to content

Commit 0f4f87e

Browse files
Haz3-joltdavid-allison
authored andcommitted
feat(note-editor): add split-pane previewer on xlarge screens
- Introduce two-pane NoteEditor layout (editor + previewer) for xlarge screens - Add NoteEditorFragmentDelegate to notify host activity of editor events (ready, text changes, save, type change) so preview can stay in sync - Implement previewer loading in NoteEditorActivity using TemplatePreviewerFragment with tab navigation for multiple card templates - Retains tab position on text change - Rename allowSaveAndPreview(): - allowSaveAction() – controls when note can be saved - allowPreviewAction() – controls when preview menu item is shown (disabled in split mode since previewer is always visible) - Added method to update existing card being previewer updateContent() without full fragment reloading and while retaining currently selected card - Added method to get safe ord for Cloze card refresh
1 parent 639a782 commit 0f4f87e

File tree

6 files changed

+482
-14
lines changed

6 files changed

+482
-14
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorActivity.kt

Lines changed: 234 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,31 @@ package com.ichi2.anki
1919
import android.content.Context
2020
import android.content.Intent
2121
import android.os.Bundle
22+
import androidx.core.view.isVisible
2223
import androidx.fragment.app.Fragment
24+
import androidx.fragment.app.FragmentContainerView
2325
import androidx.fragment.app.commit
2426
import com.ichi2.anki.NoteEditorActivity.Companion.FRAGMENT_ARGS_EXTRA
2527
import com.ichi2.anki.NoteEditorActivity.Companion.FRAGMENT_NAME_EXTRA
2628
import com.ichi2.anki.android.input.ShortcutGroup
2729
import com.ichi2.anki.android.input.ShortcutGroupProvider
30+
import com.ichi2.anki.databinding.NoteEditorBinding
2831
import com.ichi2.anki.libanki.Collection
32+
import com.ichi2.anki.noteeditor.NoteEditorFragmentDelegate
2933
import com.ichi2.anki.noteeditor.NoteEditorLauncher
34+
import com.ichi2.anki.previewer.TemplatePreviewerArguments
35+
import com.ichi2.anki.previewer.TemplatePreviewerFragment
36+
import com.ichi2.anki.settings.Prefs
3037
import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
3138
import com.ichi2.anki.snackbar.SnackbarBuilder
39+
import com.ichi2.anki.ui.ResizablePaneManager
40+
import com.ichi2.themes.Themes
41+
import kotlinx.coroutines.Job
42+
import kotlinx.coroutines.delay
3243
import timber.log.Timber
3344
import kotlin.reflect.KClass
3445
import kotlin.reflect.jvm.jvmName
46+
import kotlin.time.Duration.Companion.milliseconds
3547

3648
/**
3749
* To find the actual note Editor, @see [NoteEditorFragment]
@@ -44,14 +56,37 @@ class NoteEditorActivity :
4456
AnkiActivity(),
4557
BaseSnackbarBuilderProvider,
4658
DispatchKeyEventListener,
47-
ShortcutGroupProvider {
59+
ShortcutGroupProvider,
60+
NoteEditorFragmentDelegate {
4861
override val baseSnackbarBuilder: SnackbarBuilder = { }
4962

5063
lateinit var noteEditorFragment: NoteEditorFragment
5164

5265
private val mainToolbar: androidx.appcompat.widget.Toolbar
5366
get() = findViewById(R.id.toolbar)
5467

68+
/**
69+
* Reference to the previewer container that exists only on larger screens.
70+
* Non-null if and only if the layout is x-large and includes the previewer frame
71+
*
72+
* Unlike lateinit variables, this will remain null throughout the activity
73+
* lifecycle on smaller screens that don't include the previewer frame.
74+
*
75+
* Fragmentation is determined by this view's visibility after inflation.
76+
*/
77+
private var previewerFrame: FragmentContainerView? = null
78+
79+
/**
80+
* Job for managing delayed previewer refresh operations.
81+
* Automatically cancelled when the lifecycle scope is destroyed, preventing memory leaks.
82+
*/
83+
private var refreshPreviewerJob: Job? = null
84+
85+
val fragmented: Boolean
86+
get() = previewerFrame?.isVisible == true
87+
88+
private lateinit var binding: NoteEditorBinding
89+
5590
override fun onCreate(savedInstanceState: Bundle?) {
5691
if (showedActivityFailedScreen(savedInstanceState)) {
5792
return
@@ -61,7 +96,11 @@ class NoteEditorActivity :
6196
return
6297
}
6398

64-
setContentView(R.layout.note_editor)
99+
binding = NoteEditorBinding.inflate(layoutInflater)
100+
setContentView(binding.root)
101+
102+
previewerFrame = binding.previewerFrame
103+
Timber.i("Note Editor is in %s mode", if (fragmented) "split" else "single-pane")
65104

66105
val launcher = NoteIntentParser.parse(intent)
67106

@@ -78,10 +117,12 @@ class NoteEditorActivity :
78117
*/
79118
runOnCommit {
80119
noteEditorFragment = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as NoteEditorFragment
120+
noteEditorFragment.setDelegate(this@NoteEditorActivity)
81121
}
82122
}
83123
} else {
84124
noteEditorFragment = existingFragment as NoteEditorFragment
125+
noteEditorFragment.setDelegate(this)
85126
}
86127

87128
enableToolbar()
@@ -93,9 +134,153 @@ class NoteEditorActivity :
93134
onBackPressedDispatcher.onBackPressed()
94135
}
95136

137+
if (fragmented) {
138+
// Defer previewer loading to avoid blocking onCreate
139+
binding.previewerFrame!!.post {
140+
loadNoteEditorPreviewer(true)
141+
}
142+
val parentLayout = binding.noteEditorXlView!!
143+
val divider = binding.noteEditorResizingDivider!!
144+
val noteEditorPane = binding.noteEditorFragmentFrame
145+
val previewerPane = binding.previewerFrameLayout!!
146+
ResizablePaneManager(
147+
parentLayout = parentLayout,
148+
divider = divider,
149+
leftPane = noteEditorPane,
150+
rightPane = previewerPane,
151+
sharedPrefs = Prefs.getUiConfig(this),
152+
leftPaneWeightKey = PREF_NOTE_EDITOR_PANE_WEIGHT,
153+
rightPaneWeightKey = PREF_PREVIEWER_PANE_WEIGHT,
154+
)
155+
}
156+
96157
startLoadingCollection()
97158
}
98159

160+
/**
161+
* Loads and configures the note editor previewer.
162+
*
163+
* This method orchestrates the entire preview process including:
164+
* - Processing the current note fields and tags
165+
* - Setting up the previewer fragment with the appropriate configuration
166+
* - Configuring the tab layout for card template navigation
167+
*
168+
* The preview will reflect the current state of the note being edited,
169+
* allowing users to see how their cards will appear during review.
170+
*
171+
* BUG: Fragment replacement loses user state
172+
* - Scroll position is reset when user has scrolled through long card content
173+
*
174+
* Ideally, we should:
175+
* Preserve scroll position when possible
176+
*
177+
* State preservation behavior:
178+
* - Regular templates: Content-only updates preserve tab selection and front/back state
179+
* - Cloze notes: Fragment recreation with preserved tab selection to handle dynamic cloze changes
180+
*/
181+
fun loadNoteEditorPreviewer(forceReplace: Boolean) {
182+
if (!fragmented) {
183+
return
184+
}
185+
186+
// Check if noteEditorFragment is initialized before proceeding
187+
if (!::noteEditorFragment.isInitialized) {
188+
Timber.w("loadNoteEditorPreviewer called before noteEditorFragment was initialized")
189+
return
190+
}
191+
192+
// Check if editorNote is available before proceeding
193+
val note =
194+
noteEditorFragment.editorNote ?: run {
195+
Timber.w("loadNoteEditorPreviewer called before editorNote was available")
196+
return
197+
}
198+
199+
launchCatchingTask {
200+
try {
201+
val fields = noteEditorFragment.prepareNoteFields()
202+
val tags = noteEditorFragment.selectedTags ?: mutableListOf()
203+
204+
fun updatePreviewerFragment(ord: Int) {
205+
val previewerFragment = createPreviewerFragment(fields, tags, ord)
206+
supportFragmentManager.commit {
207+
replace(R.id.previewer_frame, previewerFragment)
208+
runOnCommit {
209+
configurePreviewerTabs(previewerFragment)
210+
}
211+
}
212+
}
213+
214+
val existingPreviewer = supportFragmentManager.findFragmentById(R.id.previewer_frame)
215+
216+
when {
217+
forceReplace -> {
218+
updatePreviewerFragment(ord = noteEditorFragment.determineCardOrdinal(fields))
219+
}
220+
existingPreviewer is TemplatePreviewerFragment -> {
221+
if (note.notetype.isCloze) {
222+
// For cloze notes, force recreation to handle dynamic cloze changes
223+
// but preserve the user's selected tab
224+
updatePreviewerFragment(ord = existingPreviewer.getSafeClozeOrd())
225+
} else {
226+
// For regular templates, just update content
227+
existingPreviewer.updateContent(fields, tags)
228+
}
229+
}
230+
}
231+
} catch (e: Exception) {
232+
Timber.w(e, "Failed to load note editor previewer")
233+
}
234+
}
235+
}
236+
237+
/**
238+
* Creates and configures the template previewer fragment.
239+
*
240+
* @param fields The processed note fields
241+
* @param tags The selected tags for the note
242+
* @param ord The ordinal (position) of the card template to display
243+
* @return The configured previewer fragment
244+
*/
245+
private fun createPreviewerFragment(
246+
fields: List<String>,
247+
tags: List<String>,
248+
ord: Int,
249+
): TemplatePreviewerFragment {
250+
val args =
251+
TemplatePreviewerArguments(
252+
notetypeFile =
253+
NotetypeFile(
254+
this@NoteEditorActivity,
255+
noteEditorFragment.editorNote!!.notetype,
256+
),
257+
fields = fields,
258+
tags = tags,
259+
id = noteEditorFragment.editorNote!!.id,
260+
ord = ord,
261+
fillEmpty = false,
262+
)
263+
264+
val backgroundColor = Themes.getColorFromAttr(this@NoteEditorActivity, R.attr.alternativeBackgroundColor)
265+
val previewerFragment = TemplatePreviewerFragment.newInstance(args, backgroundColor)
266+
return previewerFragment
267+
}
268+
269+
/**
270+
* Configures the tab layout for the previewer with appropriate tabs for each card template.
271+
* Delegates the tab setup responsibility to the TemplatePreviewerFragment
272+
*
273+
* @param previewerFragment The previewer fragment that will manage the tabs
274+
*/
275+
private fun configurePreviewerTabs(previewerFragment: TemplatePreviewerFragment) {
276+
// Post to ensure the fragment is attached before accessing its viewModel
277+
binding.previewerFrame?.post {
278+
if (!previewerFragment.isAdded) return@post
279+
val previewerTabLayout = binding.previewerTabLayout!!
280+
previewerFragment.setupTabs(previewerTabLayout)
281+
}
282+
}
283+
99284
override fun onCollectionLoaded(col: Collection) {
100285
super.onCollectionLoaded(col)
101286
Timber.d("onCollectionLoaded()")
@@ -108,11 +293,58 @@ class NoteEditorActivity :
108293
override val shortcuts: ShortcutGroup
109294
get() = noteEditorFragment.shortcuts
110295

296+
override fun onResume() {
297+
super.onResume()
298+
// Refresh the previewer when activity resumes, if needed
299+
if (fragmented) {
300+
loadNoteEditorPreviewer(false)
301+
}
302+
}
303+
304+
//region NoteEditorFragmentDelegate Protocol Methods
305+
306+
override fun onNoteEditorReady() {
307+
// Load the if fragmented, else does nothing
308+
if (!fragmented) return
309+
310+
loadNoteEditorPreviewer(false)
311+
}
312+
313+
override fun onNoteTextChanged() {
314+
if (!fragmented) return
315+
316+
refreshPreviewerJob?.cancel()
317+
refreshPreviewerJob =
318+
launchCatchingTask {
319+
delay(REFRESH_NOTE_EDITOR_PREVIEW_DELAY)
320+
loadNoteEditorPreviewer(false)
321+
}
322+
}
323+
324+
override fun onNoteSaved() {
325+
if (!fragmented) return
326+
327+
loadNoteEditorPreviewer(true)
328+
}
329+
330+
override fun onNoteTypeChanged() {
331+
if (!fragmented) return
332+
333+
loadNoteEditorPreviewer(true)
334+
}
335+
//endregion
336+
111337
companion object {
112338
const val FRAGMENT_ARGS_EXTRA = "fragmentArgs"
113339
const val FRAGMENT_NAME_EXTRA = "fragmentName"
114340
const val FRAGMENT_TAG = "NoteEditorFragmentTag"
115341

342+
// Keys for saving pane weights in SharedPreferences
343+
private const val PREF_NOTE_EDITOR_PANE_WEIGHT = "noteEditorPaneWeight"
344+
private const val PREF_PREVIEWER_PANE_WEIGHT = "previewerPaneWeight"
345+
346+
private val REFRESH_NOTE_EDITOR_PREVIEW_DELAY = 100.milliseconds
347+
116348
/**
117349
* Creates an Intent to launch the NoteEditor activity with a specific fragment class and arguments.
118350
*

0 commit comments

Comments
 (0)