mirror of
https://github.com/Darkrock-Studios/hammer-editor.git
synced 2026-10-19 09:34:23 +00:00
ComposeTextEditor 3.0.2 and a render-time editor text size (#973)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Update ComposeTextEditor to 3.0.2 Styles move to TextEditorState.richTextStyles, headings use the block API so they export as headings, and shortcuts match on layoutKey. Markdown pins ParagraphSeparator.NEWLINE to keep the 2.8 on-disk format. * Make the editor text size a render property Rich text styles are a fixed set with em-relative headings, so the body size lives on the editor text style and changing it rewrites no document. The size steps through a ladder, shows its value in the format bar menu, and answers Ctrl+Plus, Minus and 0.
This commit is contained in:
parent
5d363ee7c4
commit
ca22ec262d
26 changed files with 528 additions and 389 deletions
|
|
@ -35,6 +35,7 @@
|
|||
<string name="expand">Expand</string>
|
||||
<string name="collapse">Collapse</string>
|
||||
|
||||
<string name="markdown_format_bar_text_size">Text size</string>
|
||||
<string name="markdown_format_bar_decrease_text_size">Decrease text size</string>
|
||||
<string name="markdown_format_bar_increase_text_size">Increase text size</string>
|
||||
<string name="markdown_format_bar_reset_text_size">Reset text size</string>
|
||||
|
|
|
|||
|
|
@ -3,17 +3,15 @@ package com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneedito
|
|||
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
|
||||
import korlibs.memory.clamp
|
||||
|
||||
val MIN_FONT_SIZE = 8f
|
||||
val MAX_FONT_SIZE = 32f
|
||||
/** The body text sizes the editor steps through. A size off the ladder snaps to it on the next step. */
|
||||
val EDITOR_TEXT_SIZES = listOf(12f, 14f, 16f, 18f, 20f, 24f, 28f, 32f)
|
||||
|
||||
fun increaseEditorTextSize(currentSize: Float): Float {
|
||||
return (currentSize + 1f).clamp(MIN_FONT_SIZE, MAX_FONT_SIZE)
|
||||
}
|
||||
fun increaseEditorTextSize(currentSize: Float): Float =
|
||||
EDITOR_TEXT_SIZES.firstOrNull { it > currentSize } ?: EDITOR_TEXT_SIZES.last()
|
||||
|
||||
fun decreaseEditorTextSize(currentSize: Float): Float {
|
||||
return (currentSize - 1f).clamp(MIN_FONT_SIZE, MAX_FONT_SIZE)
|
||||
}
|
||||
fun decreaseEditorTextSize(currentSize: Float): Float =
|
||||
EDITOR_TEXT_SIZES.lastOrNull { it < currentSize } ?: EDITOR_TEXT_SIZES.first()
|
||||
|
||||
fun clampEditorWidth(width: Float): Float {
|
||||
return width.clamp(GlobalSettings.MIN_EDITOR_WIDTH, GlobalSettings.MAX_EDITOR_WIDTH)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
package components.storyeditor.sceneeditor
|
||||
|
||||
import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.EDITOR_TEXT_SIZES
|
||||
import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.decreaseEditorTextSize
|
||||
import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.increaseEditorTextSize
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class EditorTextSizeStepsTest {
|
||||
|
||||
@Test
|
||||
fun `steps walk the ladder`() {
|
||||
assertEquals(18f, increaseEditorTextSize(16f))
|
||||
assertEquals(14f, decreaseEditorTextSize(16f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a size off the ladder snaps to it`() {
|
||||
assertEquals(18f, increaseEditorTextSize(17f))
|
||||
assertEquals(16f, decreaseEditorTextSize(17f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steps stop at the ends`() {
|
||||
assertEquals(EDITOR_TEXT_SIZES.last(), increaseEditorTextSize(EDITOR_TEXT_SIZES.last()))
|
||||
assertEquals(EDITOR_TEXT_SIZES.first(), decreaseEditorTextSize(EDITOR_TEXT_SIZES.first()))
|
||||
assertEquals(EDITOR_TEXT_SIZES.last(), increaseEditorTextSize(99f))
|
||||
assertEquals(EDITOR_TEXT_SIZES.first(), decreaseEditorTextSize(1f))
|
||||
}
|
||||
}
|
||||
|
|
@ -95,6 +95,7 @@ kotlin {
|
|||
implementation(libs.aboutlibraries.core)
|
||||
implementation(libs.aboutlibraries.compose)
|
||||
implementation(libs.compose.texteditor)
|
||||
implementation(libs.compose.texteditor.markdown)
|
||||
implementation(libs.compose.texteditor.find)
|
||||
implementation(libs.compose.texteditor.spellcheck)
|
||||
implementation(libs.platform.spellcheckerkt)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.darkrockstudios.apps.hammer.common.compose
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.data.PlatformRichText
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
|
||||
import com.darkrockstudios.texteditor.markdown.toMarkdown
|
||||
|
|
@ -16,7 +17,7 @@ data class ComposeRichText(
|
|||
|
||||
override fun convertToMarkdown(): String {
|
||||
return when {
|
||||
snapshot != null -> snapshot.toMarkdown()
|
||||
snapshot != null -> snapshot.toMarkdown(HammerMarkdownConfiguration)
|
||||
state != null -> state.exportAsMarkdown()
|
||||
else -> error("ComposeRichText must contain non-null data ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.common.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.changeFontSize
|
||||
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
|
||||
|
||||
val LocalMarkdownConfig = compositionLocalOf {
|
||||
MarkdownConfiguration.DEFAULT
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProvideMarkdownConfig(
|
||||
isDark: Boolean,
|
||||
settings: GlobalSettings,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val baseMarkdownConfig =
|
||||
if (isDark) MarkdownConfiguration.DEFAULT_DARK else MarkdownConfiguration.DEFAULT
|
||||
val scaledMarkdownConfig = baseMarkdownConfig.changeFontSize(settings.editorFontSize)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalMarkdownConfig provides scaledMarkdownConfig,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.darkrockstudios.apps.hammer.common.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
|
||||
import com.darkrockstudios.texteditor.RichTextStyles
|
||||
|
||||
/**
|
||||
* Heading sizes are relative to the editor's text style, so the body size lives in
|
||||
* [LocalEditorTextStyle] alone and changing it rewrites no document.
|
||||
*/
|
||||
val HammerRichTextStyles = RichTextStyles(
|
||||
defaultTextStyle = SpanStyle(),
|
||||
header1Style = SpanStyle(fontSize = 2.em, fontWeight = FontWeight.Bold),
|
||||
header2Style = SpanStyle(fontSize = 1.5.em, fontWeight = FontWeight.Bold),
|
||||
header3Style = SpanStyle(fontSize = 1.17.em, fontWeight = FontWeight.Bold),
|
||||
header4Style = SpanStyle(fontSize = 1.em, fontWeight = FontWeight.Bold),
|
||||
header5Style = SpanStyle(fontSize = 0.83.em, fontWeight = FontWeight.Bold),
|
||||
header6Style = SpanStyle(fontSize = 0.75.em, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
val HammerRichTextStylesDark = HammerRichTextStyles.copy(
|
||||
codeStyle = RichTextStyles.DEFAULT_DARK.codeStyle,
|
||||
highlightStyle = RichTextStyles.DEFAULT_DARK.highlightStyle,
|
||||
)
|
||||
|
||||
val LocalRichTextStyles = compositionLocalOf { HammerRichTextStyles }
|
||||
|
||||
/** The editor's body text style; every rich text size scales from it. */
|
||||
val LocalEditorTextStyle = compositionLocalOf {
|
||||
TextStyle(fontSize = GlobalSettings.DEFAULT_FONT_SIZE.sp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProvideRichTextStyles(
|
||||
isDark: Boolean,
|
||||
settings: GlobalSettings,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalRichTextStyles provides if (isDark) HammerRichTextStylesDark else HammerRichTextStyles,
|
||||
LocalEditorTextStyle provides TextStyle(fontSize = settings.editorFontSize.sp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
/** A first-line indent that scales with the body size. */
|
||||
fun TextStyle.withParagraphIndent(): TextStyle = copy(textIndent = TextIndent(firstLine = 1.5.em))
|
||||
|
|
@ -2,8 +2,12 @@ package com.darkrockstudios.apps.hammer.common.compose
|
|||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.*
|
||||
import com.darkrockstudios.texteditor.input.layoutKey
|
||||
|
||||
/** Matches a key-down with exactly these modifiers: extra ones do not count as a match. */
|
||||
/**
|
||||
* Matches a key-down with exactly these modifiers: extra ones do not count as a match.
|
||||
* Letters match on [layoutKey], as the editor's built-in chords do.
|
||||
*/
|
||||
fun KeyEvent.matchesShortcut(
|
||||
key: Key,
|
||||
ctrl: Boolean = false,
|
||||
|
|
@ -12,7 +16,7 @@ fun KeyEvent.matchesShortcut(
|
|||
): Boolean {
|
||||
val ctrlOrMeta = isCtrlPressed || isMetaPressed
|
||||
return type == KeyEventType.KeyDown &&
|
||||
this.key == key &&
|
||||
layoutKey == key &&
|
||||
ctrlOrMeta == ctrl &&
|
||||
isShiftPressed == shift &&
|
||||
isAltPressed == alt
|
||||
|
|
@ -47,3 +51,18 @@ fun Modifier.italicShortcutModifier(onItalic: () -> Unit): Modifier =
|
|||
|
||||
fun Modifier.strikethroughShortcutModifier(onStrikethrough: () -> Unit): Modifier =
|
||||
onKeyShortcut(Key.X, ctrl = true, shift = true, action = onStrikethrough)
|
||||
|
||||
/** Ctrl/Cmd with Plus, Minus and 0, as browsers zoom. Plus is Shift+Equals on most layouts. */
|
||||
fun Modifier.textSizeShortcutModifier(
|
||||
onIncrease: () -> Unit,
|
||||
onDecrease: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
): Modifier = this
|
||||
.onKeyShortcut(Key.Equals, ctrl = true, action = onIncrease)
|
||||
.onKeyShortcut(Key.Equals, ctrl = true, shift = true, action = onIncrease)
|
||||
.onKeyShortcut(Key.Plus, ctrl = true, action = onIncrease)
|
||||
.onKeyShortcut(Key.NumPadAdd, ctrl = true, action = onIncrease)
|
||||
.onKeyShortcut(Key.Minus, ctrl = true, action = onDecrease)
|
||||
.onKeyShortcut(Key.NumPadSubtract, ctrl = true, action = onDecrease)
|
||||
.onKeyShortcut(Key.Zero, ctrl = true, action = onReset)
|
||||
.onKeyShortcut(Key.NumPad0, ctrl = true, action = onReset)
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.common.compose.markdown
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
|
||||
import io.github.aakira.napier.Napier
|
||||
|
||||
internal fun MarkdownConfiguration.changeFontSize(
|
||||
newBaseFontSize: Float
|
||||
): MarkdownConfiguration {
|
||||
// Determine the baseline font size to adjust from
|
||||
val baselineFontSize = defaultTextStyle.fontSize.value
|
||||
if (baselineFontSize == newBaseFontSize) return this
|
||||
|
||||
Napier.d("Font size OLD: $baselineFontSize NEW: $newBaseFontSize")
|
||||
|
||||
val scaleFactor = newBaseFontSize / baselineFontSize
|
||||
|
||||
// Create a new configuration with scaled font sizes
|
||||
// The key is to handle each property individually without relying on the copy method's handling of colors
|
||||
return MarkdownConfiguration(
|
||||
defaultTextStyle = defaultTextStyle.copy(
|
||||
fontSize = newBaseFontSize.sp
|
||||
),
|
||||
boldStyle = SpanStyle(
|
||||
fontSize = newBaseFontSize.sp,
|
||||
fontWeight = boldStyle.fontWeight,
|
||||
color = boldStyle.color,
|
||||
fontFamily = boldStyle.fontFamily,
|
||||
fontStyle = boldStyle.fontStyle,
|
||||
background = boldStyle.background
|
||||
),
|
||||
italicStyle = SpanStyle(
|
||||
fontSize = newBaseFontSize.sp,
|
||||
fontWeight = italicStyle.fontWeight,
|
||||
color = italicStyle.color,
|
||||
fontFamily = italicStyle.fontFamily,
|
||||
fontStyle = italicStyle.fontStyle,
|
||||
background = italicStyle.background
|
||||
),
|
||||
codeStyle = SpanStyle(
|
||||
fontSize = newBaseFontSize.sp,
|
||||
fontWeight = codeStyle.fontWeight,
|
||||
color = codeStyle.color,
|
||||
fontFamily = codeStyle.fontFamily,
|
||||
fontStyle = codeStyle.fontStyle,
|
||||
background = codeStyle.background
|
||||
),
|
||||
linkStyle = SpanStyle(
|
||||
fontSize = newBaseFontSize.sp,
|
||||
fontWeight = linkStyle.fontWeight,
|
||||
color = linkStyle.color,
|
||||
fontFamily = linkStyle.fontFamily,
|
||||
fontStyle = linkStyle.fontStyle,
|
||||
background = linkStyle.background
|
||||
),
|
||||
blockquoteStyle = SpanStyle(
|
||||
fontSize = newBaseFontSize.sp,
|
||||
fontWeight = blockquoteStyle.fontWeight,
|
||||
color = blockquoteStyle.color,
|
||||
fontFamily = blockquoteStyle.fontFamily,
|
||||
fontStyle = blockquoteStyle.fontStyle,
|
||||
background = blockquoteStyle.background
|
||||
),
|
||||
header1Style = SpanStyle(
|
||||
fontSize = (header1Style.fontSize.value * scaleFactor).sp,
|
||||
fontWeight = header1Style.fontWeight,
|
||||
color = header1Style.color,
|
||||
fontFamily = header1Style.fontFamily,
|
||||
fontStyle = header1Style.fontStyle,
|
||||
background = header1Style.background
|
||||
),
|
||||
header2Style = SpanStyle(
|
||||
fontSize = (header2Style.fontSize.value * scaleFactor).sp,
|
||||
fontWeight = header2Style.fontWeight,
|
||||
color = header2Style.color,
|
||||
fontFamily = header2Style.fontFamily,
|
||||
fontStyle = header2Style.fontStyle,
|
||||
background = header2Style.background
|
||||
),
|
||||
header3Style = SpanStyle(
|
||||
fontSize = (header3Style.fontSize.value * scaleFactor).sp,
|
||||
fontWeight = header3Style.fontWeight,
|
||||
color = header3Style.color,
|
||||
fontFamily = header3Style.fontFamily,
|
||||
fontStyle = header3Style.fontStyle,
|
||||
background = header3Style.background
|
||||
),
|
||||
header4Style = SpanStyle(
|
||||
fontSize = (header4Style.fontSize.value * scaleFactor).sp,
|
||||
fontWeight = header4Style.fontWeight,
|
||||
color = header4Style.color,
|
||||
fontFamily = header4Style.fontFamily,
|
||||
fontStyle = header4Style.fontStyle,
|
||||
background = header4Style.background
|
||||
),
|
||||
header5Style = SpanStyle(
|
||||
fontSize = (header5Style.fontSize.value * scaleFactor).sp,
|
||||
fontWeight = header5Style.fontWeight,
|
||||
color = header5Style.color,
|
||||
fontFamily = header5Style.fontFamily,
|
||||
fontStyle = header5Style.fontStyle,
|
||||
background = header5Style.background
|
||||
),
|
||||
header6Style = SpanStyle(
|
||||
fontSize = (header6Style.fontSize.value * scaleFactor).sp,
|
||||
fontWeight = header6Style.fontWeight,
|
||||
color = header6Style.color,
|
||||
fontFamily = header6Style.fontFamily,
|
||||
fontStyle = header6Style.fontStyle,
|
||||
background = header6Style.background
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.darkrockstudios.apps.hammer.common.compose.markdown
|
||||
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
|
||||
import com.darkrockstudios.texteditor.markdown.ParagraphSeparator
|
||||
|
||||
/**
|
||||
* One editor line per markdown line. The library default, BLANK_LINE, drops the blank lines
|
||||
* between paragraphs on import and rewrites single-newline paragraphs on export.
|
||||
*/
|
||||
val HammerMarkdownConfiguration = MarkdownConfiguration(paragraphSeparator = ParagraphSeparator.NEWLINE)
|
||||
|
|
@ -3,36 +3,34 @@ package com.darkrockstudios.apps.hammer.common.compose.markdown
|
|||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
|
||||
import com.darkrockstudios.texteditor.RichTextStyles
|
||||
import com.darkrockstudios.texteditor.state.TextEditorState
|
||||
|
||||
internal fun MarkdownExtension.updateMarkdownConfiguration(newConfig: MarkdownConfiguration) {
|
||||
val oldConfig = markdownConfiguration
|
||||
internal fun TextEditorState.updateRichTextStyles(newStyles: RichTextStyles) {
|
||||
updateMarkdownStyles(
|
||||
state = editorState,
|
||||
oldConfig = oldConfig,
|
||||
newConfig = newConfig
|
||||
state = this,
|
||||
oldConfig = richTextStyles,
|
||||
newConfig = newStyles
|
||||
)
|
||||
markdownConfiguration = newConfig
|
||||
richTextStyles = newStyles
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all text in the editor to use the new markdown configuration styles.
|
||||
* Updates all text in the editor to use the new styles.
|
||||
* This preserves the semantic meaning of styles while updating their visual appearance.
|
||||
*
|
||||
* @param state The TextEditorState to update
|
||||
* @param oldConfig The previous configuration that was used
|
||||
* @param newConfig The new configuration to apply
|
||||
* @param oldConfig The previous styles that were used
|
||||
* @param newConfig The new styles to apply
|
||||
*/
|
||||
internal fun updateMarkdownStyles(
|
||||
state: TextEditorState,
|
||||
oldConfig: MarkdownConfiguration,
|
||||
newConfig: MarkdownConfiguration
|
||||
oldConfig: RichTextStyles,
|
||||
newConfig: RichTextStyles
|
||||
) {
|
||||
if (state.textLines.isEmpty()) return
|
||||
|
||||
// Heading styles are deliberately absent: MarkdownExtension.markdownConfiguration's
|
||||
// Heading styles are deliberately absent: TextEditorState.richTextStyles'
|
||||
// setter rebakes them from their HeaderSpanStyle spans. Remapping them here too
|
||||
// leaves the rebake unable to find the old style to strip, so it appends a second
|
||||
// copy to every heading on each config change.
|
||||
|
|
@ -42,6 +40,7 @@ internal fun updateMarkdownStyles(
|
|||
oldConfig.italicStyle to newConfig.italicStyle,
|
||||
oldConfig.codeStyle to newConfig.codeStyle,
|
||||
oldConfig.linkStyle to newConfig.linkStyle,
|
||||
oldConfig.highlightStyle to newConfig.highlightStyle,
|
||||
oldConfig.blockquoteStyle to newConfig.blockquoteStyle,
|
||||
)
|
||||
|
||||
|
|
@ -98,5 +97,8 @@ private fun deepCompareSpanStyles(style1: SpanStyle, style2: SpanStyle): Boolean
|
|||
if (style1.baselineShift != style2.baselineShift) return false
|
||||
if (style1.textGeometricTransform != style2.textGeometricTransform) return false
|
||||
if (style1.localeList != style2.localeList) return false
|
||||
// What tells a heading's look from an inline style it equals (RichTextStyles.headingLook)
|
||||
if (style1.platformStyle != style2.platformStyle) return false
|
||||
if (style1.drawStyle != style2.drawStyle) return false
|
||||
return true
|
||||
}
|
||||
|
|
@ -11,19 +11,19 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.compose.rememberKoinInject
|
||||
import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent
|
||||
import com.darkrockstudios.apps.hammer.common.spellcheck.SpellCheckRepository
|
||||
import com.darkrockstudios.apps.hammer.common.utils.toEditorSpellChecker
|
||||
import com.darkrockstudios.texteditor.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.rememberTextEditorStyle
|
||||
import com.darkrockstudios.texteditor.spellcheck.SpellCheckingTextEditor
|
||||
import com.darkrockstudios.texteditor.spellcheck.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.spellcheck.rememberSpellCheckState
|
||||
|
||||
/**
|
||||
|
|
@ -46,7 +46,7 @@ fun MarkdownEditField(
|
|||
minEditorHeight: Dp = 200.dp,
|
||||
testTag: String? = null,
|
||||
) {
|
||||
val markdownConfig = LocalMarkdownConfig.current
|
||||
val richTextStyles = LocalRichTextStyles.current
|
||||
|
||||
val spellCheckRepository = rememberKoinInject<SpellCheckRepository>()
|
||||
val platformSpellChecker by spellCheckRepository.dictionaryFlow.collectAsState(initial = null)
|
||||
|
|
@ -61,7 +61,10 @@ fun MarkdownEditField(
|
|||
// so a null checker also means "disabled" — clear decorations rather than keep checking.
|
||||
enableSpellChecking = enableSpellChecking && platformSpellChecker != null,
|
||||
)
|
||||
val markdownExtension = remember { textEditorState.withMarkdown(markdownConfig) }
|
||||
val markdownExtension = remember {
|
||||
textEditorState.textState.richTextStyles = richTextStyles
|
||||
textEditorState.textState.withMarkdown(HammerMarkdownConfiguration)
|
||||
}
|
||||
|
||||
LaunchedEffect(markdownExtension) {
|
||||
markdownExtension.importMarkdown(initialMarkdown)
|
||||
|
|
@ -72,8 +75,8 @@ fun MarkdownEditField(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(markdownConfig) {
|
||||
markdownExtension.updateMarkdownConfiguration(markdownConfig)
|
||||
LaunchedEffect(richTextStyles) {
|
||||
textEditorState.textState.updateRichTextStyles(richTextStyles)
|
||||
}
|
||||
|
||||
Column(
|
||||
|
|
@ -91,9 +94,7 @@ fun MarkdownEditField(
|
|||
enabled = enabled,
|
||||
autoFocus = autoFocus,
|
||||
style = rememberTextEditorStyle(
|
||||
textStyle = TextStyle.Default.copy(
|
||||
textIndent = TextIndent(firstLine = 24.sp)
|
||||
)
|
||||
textStyle = LocalEditorTextStyle.current.withParagraphIndent()
|
||||
),
|
||||
contentPadding = contentPadding,
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ package com.darkrockstudios.apps.hammer.common.compose.markdowneditor
|
|||
|
||||
import com.darkrockstudios.texteditor.CharLineOffset
|
||||
import com.darkrockstudios.texteditor.TextEditorRange
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
|
||||
import com.darkrockstudios.texteditor.richstyle.HR_PLACEHOLDER
|
||||
import com.darkrockstudios.texteditor.richstyle.HorizontalRuleSpanStyle
|
||||
import com.darkrockstudios.texteditor.state.TextEditorState
|
||||
import com.darkrockstudios.texteditor.state.headerLevel
|
||||
import com.darkrockstudios.texteditor.state.toggleBulletList
|
||||
import com.darkrockstudios.texteditor.state.toggleHeader
|
||||
import com.darkrockstudios.texteditor.state.toggleOrderedList
|
||||
|
||||
internal fun insertHorizontalRule(state: TextEditorState) {
|
||||
state.insertNewlineAtCursor()
|
||||
|
|
@ -49,49 +52,33 @@ internal fun reconcileHorizontalRules(state: TextEditorState) {
|
|||
}
|
||||
}
|
||||
|
||||
internal fun toggleBulletList(state: TextEditorState, markdown: MarkdownExtension) {
|
||||
private fun selectedLines(state: TextEditorState): IntRange {
|
||||
val selection = state.selector.selection
|
||||
val lines = if (selection != null) {
|
||||
return if (selection != null) {
|
||||
selection.start.line..selection.end.line
|
||||
} else {
|
||||
state.cursorPosition.line..state.cursorPosition.line
|
||||
}
|
||||
markdown.toggleBulletList(lines)
|
||||
}
|
||||
|
||||
internal fun toggleOrderedList(state: TextEditorState, markdown: MarkdownExtension) {
|
||||
val selection = state.selector.selection
|
||||
val lines = if (selection != null) {
|
||||
selection.start.line..selection.end.line
|
||||
} else {
|
||||
state.cursorPosition.line..state.cursorPosition.line
|
||||
}
|
||||
markdown.toggleOrderedList(lines)
|
||||
internal fun toggleBulletList(state: TextEditorState) {
|
||||
state.toggleBulletList(selectedLines(state))
|
||||
}
|
||||
|
||||
internal fun toggleOrderedList(state: TextEditorState) {
|
||||
state.toggleOrderedList(selectedLines(state))
|
||||
}
|
||||
|
||||
internal val HEADER_CYCLE_LEVELS = 1..3
|
||||
|
||||
internal fun cycleHeader(
|
||||
state: TextEditorState,
|
||||
markdown: MarkdownExtension,
|
||||
currentLevel: Int,
|
||||
) {
|
||||
val maxLevel = HEADER_CYCLE_LEVELS.last
|
||||
val nextLevel = (currentLevel + 1) % (maxLevel + 1)
|
||||
val selection = state.selector.selection
|
||||
if (selection != null) {
|
||||
HEADER_CYCLE_LEVELS.forEach { lvl ->
|
||||
state.removeStyleSpan(selection, markdown.markdownStyles.header(lvl))
|
||||
}
|
||||
if (nextLevel != 0) {
|
||||
state.addStyleSpan(selection, markdown.markdownStyles.header(nextLevel))
|
||||
internal fun cycleHeader(state: TextEditorState, currentLevel: Int) {
|
||||
val lines = selectedLines(state)
|
||||
if (currentLevel >= HEADER_CYCLE_LEVELS.last) {
|
||||
// toggleHeader only removes a level every line already has, so clear each line by its own
|
||||
lines.forEach { line ->
|
||||
state.headerLevel(line)?.let { state.toggleHeader(line..line, it) }
|
||||
}
|
||||
} else {
|
||||
HEADER_CYCLE_LEVELS.forEach { lvl ->
|
||||
state.cursor.removeStyle(markdown.markdownStyles.header(lvl))
|
||||
}
|
||||
if (nextLevel != 0) {
|
||||
state.cursor.addStyle(markdown.markdownStyles.header(nextLevel))
|
||||
}
|
||||
state.toggleHeader(lines, currentLevel + 1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.Row
|
|||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||
|
|
@ -21,6 +23,7 @@ import androidx.compose.material3.DropdownMenu
|
|||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -33,10 +36,13 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.darkrockstudios.apps.hammer.Res
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
|
||||
import com.darkrockstudios.apps.hammer.common.compose.boldShortcutModifier
|
||||
import com.darkrockstudios.apps.hammer.common.compose.icons.EditorIcons
|
||||
import com.darkrockstudios.apps.hammer.common.compose.icons.IconBold
|
||||
|
|
@ -50,6 +56,7 @@ import com.darkrockstudios.apps.hammer.common.compose.icons.IconUndo
|
|||
import com.darkrockstudios.apps.hammer.common.compose.italicShortcutModifier
|
||||
import com.darkrockstudios.apps.hammer.common.compose.resources.get
|
||||
import com.darkrockstudios.apps.hammer.common.compose.strikethroughShortcutModifier
|
||||
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
|
||||
import com.darkrockstudios.apps.hammer.markdown_format_bar_blockquote
|
||||
import com.darkrockstudios.apps.hammer.markdown_format_bar_bold
|
||||
import com.darkrockstudios.apps.hammer.markdown_format_bar_bullet_list
|
||||
|
|
@ -62,6 +69,7 @@ import com.darkrockstudios.apps.hammer.markdown_format_bar_italic
|
|||
import com.darkrockstudios.apps.hammer.markdown_format_bar_numbered_list
|
||||
import com.darkrockstudios.apps.hammer.markdown_format_bar_redo
|
||||
import com.darkrockstudios.apps.hammer.markdown_format_bar_reset_text_size
|
||||
import com.darkrockstudios.apps.hammer.markdown_format_bar_text_size
|
||||
import com.darkrockstudios.apps.hammer.markdown_format_bar_strikethrough
|
||||
import com.darkrockstudios.apps.hammer.markdown_format_bar_undo
|
||||
import com.darkrockstudios.apps.hammer.more_menu_button
|
||||
|
|
@ -72,6 +80,7 @@ import com.darkrockstudios.texteditor.state.TextEditorState
|
|||
import com.darkrockstudios.texteditor.state.getRichSpansAtPosition
|
||||
import com.darkrockstudios.texteditor.state.getRichSpansInRange
|
||||
import com.darkrockstudios.texteditor.state.getSpanStylesInRange
|
||||
import com.darkrockstudios.texteditor.state.headerLevel
|
||||
|
||||
@Composable
|
||||
fun MarkdownFormatBar(
|
||||
|
|
@ -105,15 +114,13 @@ fun MarkdownFormatBar(
|
|||
state.getRichSpansAtPosition(position)
|
||||
}
|
||||
|
||||
isBoldActive = styles.contains(markdownState.markdownStyles.BOLD)
|
||||
isItalicActive = styles.contains(markdownState.markdownStyles.ITALICS)
|
||||
isStrikethroughActive = styles.contains(markdownState.markdownStyles.STRIKETHROUGH)
|
||||
isBlockquoteActive = styles.contains(markdownState.markdownStyles.BLOCKQUOTE)
|
||||
isBoldActive = styles.contains(state.richTextStyles.boldStyle)
|
||||
isItalicActive = styles.contains(state.richTextStyles.italicStyle)
|
||||
isStrikethroughActive = styles.contains(state.richTextStyles.strikethroughStyle)
|
||||
isBlockquoteActive = styles.contains(state.richTextStyles.blockquoteStyle)
|
||||
isBulletListActive = richSpans.any { it.style === BulletListSpanStyle }
|
||||
isOrderedListActive = richSpans.any { it.style === OrderedListSpanStyle }
|
||||
currentHeaderLevel = HEADER_CYCLE_LEVELS.firstOrNull { lvl ->
|
||||
styles.contains(markdownState.markdownStyles.header(lvl))
|
||||
} ?: 0
|
||||
currentHeaderLevel = state.headerLevel(selection?.start?.line ?: position.line) ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +141,6 @@ fun MarkdownFormatBar(
|
|||
Row(modifier = rowModifier) {
|
||||
FormatButtons(
|
||||
state = state,
|
||||
markdownState = markdownState,
|
||||
isBoldActive = isBoldActive,
|
||||
isItalicActive = isItalicActive,
|
||||
isStrikethroughActive = isStrikethroughActive,
|
||||
|
|
@ -167,7 +173,6 @@ private val TOOLBAR_COMPACT_THRESHOLD = 520.dp
|
|||
@Composable
|
||||
private fun RowScope.FormatButtons(
|
||||
state: TextEditorState,
|
||||
markdownState: MarkdownExtension,
|
||||
isBoldActive: Boolean,
|
||||
isItalicActive: Boolean,
|
||||
isStrikethroughActive: Boolean,
|
||||
|
|
@ -181,7 +186,7 @@ private fun RowScope.FormatButtons(
|
|||
icon = EditorIcons.IconBold,
|
||||
active = isBoldActive,
|
||||
) {
|
||||
toggleStyle(state, markdownState.markdownStyles.BOLD)
|
||||
toggleStyle(state, state.richTextStyles.boldStyle)
|
||||
}
|
||||
}
|
||||
EditorTooltip("${Res.string.markdown_format_bar_italic.get()} (${shortcutHint("I")})") {
|
||||
|
|
@ -189,7 +194,7 @@ private fun RowScope.FormatButtons(
|
|||
icon = EditorIcons.IconItalic,
|
||||
active = isItalicActive,
|
||||
) {
|
||||
toggleStyle(state, markdownState.markdownStyles.ITALICS)
|
||||
toggleStyle(state, state.richTextStyles.italicStyle)
|
||||
}
|
||||
}
|
||||
EditorTooltip(
|
||||
|
|
@ -204,7 +209,7 @@ private fun RowScope.FormatButtons(
|
|||
icon = EditorIcons.IconStrikethrough,
|
||||
active = isStrikethroughActive,
|
||||
) {
|
||||
toggleStyle(state, markdownState.markdownStyles.STRIKETHROUGH)
|
||||
toggleStyle(state, state.richTextStyles.strikethroughStyle)
|
||||
}
|
||||
}
|
||||
EditorTooltip(Res.string.markdown_format_bar_heading.get()) {
|
||||
|
|
@ -212,7 +217,7 @@ private fun RowScope.FormatButtons(
|
|||
label = if (currentHeaderLevel == 0) "H" else "H$currentHeaderLevel",
|
||||
active = currentHeaderLevel != 0,
|
||||
) {
|
||||
cycleHeader(state, markdownState, currentHeaderLevel)
|
||||
cycleHeader(state, currentHeaderLevel)
|
||||
}
|
||||
}
|
||||
EditorTooltip(Res.string.markdown_format_bar_blockquote.get()) {
|
||||
|
|
@ -220,7 +225,7 @@ private fun RowScope.FormatButtons(
|
|||
icon = Icons.Default.FormatQuote,
|
||||
active = isBlockquoteActive,
|
||||
) {
|
||||
toggleStyle(state, markdownState.markdownStyles.BLOCKQUOTE)
|
||||
toggleStyle(state, state.richTextStyles.blockquoteStyle)
|
||||
}
|
||||
}
|
||||
EditorTooltip(Res.string.markdown_format_bar_bullet_list.get()) {
|
||||
|
|
@ -228,7 +233,7 @@ private fun RowScope.FormatButtons(
|
|||
icon = Icons.AutoMirrored.Filled.FormatListBulleted,
|
||||
active = isBulletListActive,
|
||||
) {
|
||||
toggleBulletList(state, markdownState)
|
||||
toggleBulletList(state)
|
||||
}
|
||||
}
|
||||
EditorTooltip(Res.string.markdown_format_bar_numbered_list.get()) {
|
||||
|
|
@ -236,7 +241,7 @@ private fun RowScope.FormatButtons(
|
|||
icon = Icons.Default.FormatListNumbered,
|
||||
active = isOrderedListActive,
|
||||
) {
|
||||
toggleOrderedList(state, markdownState)
|
||||
toggleOrderedList(state)
|
||||
}
|
||||
}
|
||||
EditorTooltip(Res.string.markdown_format_bar_horizontal_rule.get()) {
|
||||
|
|
@ -321,49 +326,60 @@ private fun HistoryAndOverflow(
|
|||
},
|
||||
)
|
||||
}
|
||||
if (decreaseTextSize != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(Res.string.markdown_format_bar_decrease_text_size.get()) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = EditorIcons.IconTextDecrease,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
decreaseTextSize()
|
||||
menuExpanded = false
|
||||
},
|
||||
if (decreaseTextSize != null || increaseTextSize != null || resetTextSize != null) {
|
||||
TextSizeMenuRow(decreaseTextSize, increaseTextSize, resetTextSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Steps the body text size and shows it; the menu stays open while stepping. */
|
||||
@Composable
|
||||
private fun TextSizeMenuRow(
|
||||
decreaseTextSize: (() -> Unit)?,
|
||||
increaseTextSize: (() -> Unit)?,
|
||||
resetTextSize: (() -> Unit)?,
|
||||
) {
|
||||
val textSize = LocalEditorTextStyle.current.fontSize.value
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = Res.string.markdown_format_bar_text_size.get(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
if (decreaseTextSize != null) {
|
||||
IconButton(onClick = decreaseTextSize) {
|
||||
Icon(
|
||||
imageVector = EditorIcons.IconTextDecrease,
|
||||
contentDescription = Res.string.markdown_format_bar_decrease_text_size.get(),
|
||||
)
|
||||
}
|
||||
if (increaseTextSize != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(Res.string.markdown_format_bar_increase_text_size.get()) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = EditorIcons.IconTextIncrease,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
increaseTextSize()
|
||||
menuExpanded = false
|
||||
},
|
||||
}
|
||||
Text(
|
||||
text = textSize.toInt().toString(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(min = 28.dp),
|
||||
)
|
||||
if (increaseTextSize != null) {
|
||||
IconButton(onClick = increaseTextSize) {
|
||||
Icon(
|
||||
imageVector = EditorIcons.IconTextIncrease,
|
||||
contentDescription = Res.string.markdown_format_bar_increase_text_size.get(),
|
||||
)
|
||||
}
|
||||
if (resetTextSize != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(Res.string.markdown_format_bar_reset_text_size.get()) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = EditorIcons.IconTextReset,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
resetTextSize()
|
||||
menuExpanded = false
|
||||
},
|
||||
}
|
||||
if (resetTextSize != null) {
|
||||
IconButton(
|
||||
onClick = resetTextSize,
|
||||
enabled = textSize != GlobalSettings.DEFAULT_FONT_SIZE,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = EditorIcons.IconTextReset,
|
||||
contentDescription = Res.string.markdown_format_bar_reset_text_size.get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -379,9 +395,9 @@ private fun HistoryAndOverflow(
|
|||
fun Modifier.markdownFormatShortcuts(markdownExtension: MarkdownExtension): Modifier {
|
||||
val state = markdownExtension.editorState
|
||||
return this
|
||||
.boldShortcutModifier { toggleStyle(state, markdownExtension.markdownStyles.BOLD) }
|
||||
.italicShortcutModifier { toggleStyle(state, markdownExtension.markdownStyles.ITALICS) }
|
||||
.strikethroughShortcutModifier { toggleStyle(state, markdownExtension.markdownStyles.STRIKETHROUGH) }
|
||||
.boldShortcutModifier { toggleStyle(state, state.richTextStyles.boldStyle) }
|
||||
.italicShortcutModifier { toggleStyle(state, state.richTextStyles.italicStyle) }
|
||||
.strikethroughShortcutModifier { toggleStyle(state, state.richTextStyles.strikethroughStyle) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.text
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent
|
||||
import com.darkrockstudios.texteditor.RichTextView
|
||||
import com.darkrockstudios.texteditor.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.rememberTextEditorStyle
|
||||
|
|
@ -32,9 +32,12 @@ fun MarkdownView(
|
|||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
) {
|
||||
val markdownConfig = LocalMarkdownConfig.current
|
||||
val richTextStyles = LocalRichTextStyles.current
|
||||
val state = rememberTextEditorState()
|
||||
val markdownExtension = remember(state) { state.withMarkdown(markdownConfig) }
|
||||
val markdownExtension = remember(state) {
|
||||
state.richTextStyles = richTextStyles
|
||||
state.withMarkdown(HammerMarkdownConfiguration)
|
||||
}
|
||||
LaunchedEffect(markdownExtension, markdown) {
|
||||
markdownExtension.importMarkdown(markdown)
|
||||
}
|
||||
|
|
@ -43,9 +46,7 @@ fun MarkdownView(
|
|||
modifier = modifier.semantics { text = AnnotatedString(markdown) },
|
||||
contentPadding = contentPadding,
|
||||
style = rememberTextEditorStyle(
|
||||
textStyle = TextStyle.Default.copy(
|
||||
textIndent = TextIndent(firstLine = 24.sp)
|
||||
)
|
||||
textStyle = LocalEditorTextStyle.current.withParagraphIndent()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.darkrockstudios.apps.hammer.common.compose.ProvideMarkdownConfig
|
||||
import com.darkrockstudios.apps.hammer.common.compose.ProvideRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.data.globalsettings.GlobalSettings
|
||||
|
||||
|
||||
|
|
@ -115,7 +115,7 @@ fun AppTheme(
|
|||
shapes = HammerShapes,
|
||||
typography = hammerTypography(),
|
||||
) {
|
||||
ProvideMarkdownConfig(isDark = useDarkTheme, settings = settings) {
|
||||
ProvideRichTextStyles(isDark = useDarkTheme, settings = settings) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
|
||||
import com.darkrockstudios.apps.hammer.common.compose.WindowWidthSizeClass
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
|
|
@ -21,18 +22,20 @@ import com.darkrockstudios.apps.hammer.base.diff.OffsetMap
|
|||
import com.darkrockstudios.apps.hammer.common.components.storyeditor.drafts.DraftCompare
|
||||
import com.darkrockstudios.apps.hammer.common.compose.*
|
||||
import com.darkrockstudios.apps.hammer.common.compose.designsystem.*
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.compose.resources.get
|
||||
import com.darkrockstudios.apps.hammer.common.compose.theme.LocalHammerColors
|
||||
import com.darkrockstudios.apps.hammer.common.data.SceneContent
|
||||
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent
|
||||
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneDiffText
|
||||
import com.darkrockstudios.texteditor.CharLineOffset
|
||||
import com.darkrockstudios.texteditor.RichTextStyles
|
||||
import com.darkrockstudios.texteditor.TextEditor
|
||||
import com.darkrockstudios.texteditor.TextEditorRange
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
|
||||
import com.darkrockstudios.texteditor.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.rememberTextEditorStyle
|
||||
import com.darkrockstudios.texteditor.richstyle.HighlightSpanStyle
|
||||
import com.darkrockstudios.texteditor.richstyle.RichSpan
|
||||
import com.darkrockstudios.texteditor.state.TextEditorState
|
||||
|
|
@ -54,16 +57,16 @@ fun DraftCompareUi(component: DraftCompare) {
|
|||
val screen = LocalScreenCharacteristic.current
|
||||
val state by component.state.subscribeAsState()
|
||||
val draftName = component.draftDef.draftName
|
||||
val markdownConfig = LocalMarkdownConfig.current
|
||||
val richTextStyles = LocalRichTextStyles.current
|
||||
|
||||
// Hoisted above the width branch: the two layouts are separate composables, so building
|
||||
// the editors inside them would discard the user's merge edits on a resize or rotate
|
||||
// while component.mergedContent still pointed at the torn-down editor.
|
||||
val draftMarkdown = key(state.draftContent) {
|
||||
rememberSceneContentEditor(state.draftContent, markdownConfig)
|
||||
rememberSceneContentEditor(state.draftContent, richTextStyles)
|
||||
}
|
||||
val currentMarkdown = key(state.sceneContent) {
|
||||
rememberSceneContentEditor(state.sceneContent, markdownConfig)
|
||||
rememberSceneContentEditor(state.sceneContent, richTextStyles)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
|
|
@ -252,6 +255,7 @@ private fun DraftPane(
|
|||
TextEditor(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = textEditorState,
|
||||
style = rememberTextEditorStyle(textStyle = LocalEditorTextStyle.current),
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
|
|
@ -324,6 +328,7 @@ private fun CurrentPane(
|
|||
TextEditor(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = textEditorState,
|
||||
style = rememberTextEditorStyle(textStyle = LocalEditorTextStyle.current),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -333,16 +338,17 @@ private fun CurrentPane(
|
|||
@Composable
|
||||
private fun rememberSceneContentEditor(
|
||||
content: SceneContent?,
|
||||
markdownConfig: MarkdownConfiguration,
|
||||
richTextStyles: RichTextStyles,
|
||||
): MarkdownExtension {
|
||||
val editorState = rememberTextEditorState()
|
||||
val markdownExtension = remember(editorState) {
|
||||
editorState.withMarkdown(markdownConfig).also { loadSceneContent(it, content) }
|
||||
editorState.richTextStyles = richTextStyles
|
||||
editorState.withMarkdown(HammerMarkdownConfiguration).also { loadSceneContent(it, content) }
|
||||
}
|
||||
// Both panes restyle together; the config is otherwise baked in at import time and
|
||||
// Both panes restyle together; the styles are otherwise baked in at import time and
|
||||
// the two would drift apart on a font size or theme change.
|
||||
LaunchedEffect(markdownExtension, markdownConfig) {
|
||||
markdownExtension.updateMarkdownConfiguration(markdownConfig)
|
||||
LaunchedEffect(markdownExtension, richTextStyles) {
|
||||
editorState.updateRichTextStyles(richTextStyles)
|
||||
}
|
||||
return markdownExtension
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,25 +30,26 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.darkrockstudios.apps.hammer.Res
|
||||
import com.darkrockstudios.apps.hammer.common.TextEditorDefaults
|
||||
import com.darkrockstudios.apps.hammer.common.components.storyeditor.focusmode.FocusMode
|
||||
import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.clampEditorWidth
|
||||
import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.compose.Ui
|
||||
import com.darkrockstudios.apps.hammer.common.compose.findShortcutModifier
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.rememberDefaultDispatcher
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdResizeHandle
|
||||
import com.darkrockstudios.apps.hammer.common.compose.designsystem.rememberHdResizeHandleState
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.MarkdownFormatBar
|
||||
import com.darkrockstudios.apps.hammer.common.compose.resources.get
|
||||
import com.darkrockstudios.apps.hammer.common.compose.textSizeShortcutModifier
|
||||
import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent
|
||||
import com.darkrockstudios.apps.hammer.common.data.UpdateSource
|
||||
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent
|
||||
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneContentMarkdown
|
||||
|
|
@ -58,9 +59,9 @@ import com.darkrockstudios.apps.hammer.scene_editor_add_to_dictionary
|
|||
import com.darkrockstudios.apps.hammer.scene_editor_menu_item_close
|
||||
import com.darkrockstudios.texteditor.find.FindBar
|
||||
import com.darkrockstudios.texteditor.find.rememberFindState
|
||||
import com.darkrockstudios.texteditor.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.rememberTextEditorStyle
|
||||
import com.darkrockstudios.texteditor.spellcheck.SpellCheckingTextEditor
|
||||
import com.darkrockstudios.texteditor.spellcheck.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.spellcheck.rememberSpellCheckState
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
|
@ -68,7 +69,7 @@ import kotlinx.coroutines.withContext
|
|||
fun FocusModeUi(component: FocusMode) {
|
||||
val state by component.state.subscribeAsState()
|
||||
val lastForceUpdate by component.lastForceUpdate.subscribeAsState()
|
||||
val markdownConfig = LocalMarkdownConfig.current
|
||||
val richTextStyles = LocalRichTextStyles.current
|
||||
val defaultDispatcher = rememberDefaultDispatcher()
|
||||
|
||||
val addToDictionaryLabel = Res.string.scene_editor_add_to_dictionary.get()
|
||||
|
|
@ -83,13 +84,16 @@ fun FocusModeUi(component: FocusMode) {
|
|||
initialText = null,
|
||||
enableSpellChecking = state.spellCheckingEnabled,
|
||||
)
|
||||
val markdownExtension = remember { textEditorState.withMarkdown(markdownConfig) }
|
||||
val markdownExtension = remember {
|
||||
textEditorState.textState.richTextStyles = richTextStyles
|
||||
textEditorState.textState.withMarkdown(HammerMarkdownConfiguration)
|
||||
}
|
||||
|
||||
val findState = rememberFindState(textEditorState.textState)
|
||||
var showFindBar by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(markdownConfig) {
|
||||
markdownExtension.updateMarkdownConfiguration(markdownConfig)
|
||||
LaunchedEffect(richTextStyles) {
|
||||
textEditorState.textState.updateRichTextStyles(richTextStyles)
|
||||
}
|
||||
|
||||
var hasReceivedInitialBuffer by remember { mutableStateOf(false) }
|
||||
|
|
@ -136,6 +140,11 @@ fun FocusModeUi(component: FocusMode) {
|
|||
.fillMaxSize()
|
||||
.focusModeChromePadding()
|
||||
.findShortcutModifier { showFindBar = true }
|
||||
.textSizeShortcutModifier(
|
||||
onIncrease = component::increaseTextSize,
|
||||
onDecrease = component::decreaseTextSize,
|
||||
onReset = component::resetTextSize,
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -201,9 +210,7 @@ fun FocusModeUi(component: FocusMode) {
|
|||
enabled = hasReceivedInitialBuffer,
|
||||
spellCheckMenuItems = spellCheckMenuItems,
|
||||
style = rememberTextEditorStyle(
|
||||
textStyle = TextStyle.Default.copy(
|
||||
textIndent = TextIndent(firstLine = 24.sp)
|
||||
),
|
||||
textStyle = LocalEditorTextStyle.current.withParagraphIndent(),
|
||||
focusedBorderColor = Color.Transparent,
|
||||
),
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -40,11 +40,8 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.darkrockstudios.apps.hammer.Res
|
||||
import com.darkrockstudios.apps.hammer.common.TextEditorDefaults
|
||||
|
|
@ -52,19 +49,23 @@ import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor
|
|||
import com.darkrockstudios.apps.hammer.common.components.storyeditor.sceneeditor.clampEditorWidth
|
||||
import com.darkrockstudios.apps.hammer.common.compose.AnimatedDialog
|
||||
import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalMarkdownConfig
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalEditorTextStyle
|
||||
import com.darkrockstudios.apps.hammer.common.compose.LocalRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.compose.RootSnackbarHostState
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.rememberDefaultDispatcher
|
||||
import com.darkrockstudios.apps.hammer.common.compose.Toaster
|
||||
import com.darkrockstudios.apps.hammer.common.compose.Ui
|
||||
import com.darkrockstudios.apps.hammer.common.compose.findShortcutModifier
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.compose.designsystem.HdResizeHandle
|
||||
import com.darkrockstudios.apps.hammer.common.compose.designsystem.rememberHdResizeHandleState
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.MarkdownFormatBar
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.markdownFormatShortcuts
|
||||
import com.darkrockstudios.apps.hammer.common.compose.resources.get
|
||||
import com.darkrockstudios.apps.hammer.common.compose.saveShortcutModifier
|
||||
import com.darkrockstudios.apps.hammer.common.compose.textSizeShortcutModifier
|
||||
import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent
|
||||
import com.darkrockstudios.apps.hammer.common.data.UpdateSource
|
||||
import com.darkrockstudios.apps.hammer.common.storyeditor.scenelist.SceneDeleteDialog
|
||||
import com.darkrockstudios.apps.hammer.common.utils.addToDictionaryMenuItems
|
||||
|
|
@ -72,10 +73,10 @@ import com.darkrockstudios.apps.hammer.common.utils.toEditorSpellChecker
|
|||
import com.darkrockstudios.apps.hammer.scene_editor_add_to_dictionary
|
||||
import com.darkrockstudios.texteditor.find.FindBar
|
||||
import com.darkrockstudios.texteditor.find.rememberFindState
|
||||
import com.darkrockstudios.texteditor.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.rememberTextEditorStyle
|
||||
import com.darkrockstudios.texteditor.spellcheck.SpellCheckMode
|
||||
import com.darkrockstudios.texteditor.spellcheck.SpellCheckingTextEditor
|
||||
import com.darkrockstudios.texteditor.spellcheck.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.spellcheck.rememberSpellCheckState
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -93,7 +94,7 @@ fun SceneEditorUi(
|
|||
) {
|
||||
val state by component.state.subscribeAsState()
|
||||
val lastForceUpdate by component.lastForceUpdate.subscribeAsState()
|
||||
val markdownConfig = LocalMarkdownConfig.current
|
||||
val richTextStyles = LocalRichTextStyles.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val defaultDispatcher = rememberDefaultDispatcher()
|
||||
|
||||
|
|
@ -110,13 +111,16 @@ fun SceneEditorUi(
|
|||
enableSpellChecking = state.spellCheckingEnabled,
|
||||
spellCheckMode = SpellCheckMode.Word,
|
||||
)
|
||||
val markdownExtension = remember { textEditorState.withMarkdown(markdownConfig) }
|
||||
val markdownExtension = remember {
|
||||
textEditorState.textState.richTextStyles = richTextStyles
|
||||
textEditorState.textState.withMarkdown(HammerMarkdownConfiguration)
|
||||
}
|
||||
|
||||
val findState = rememberFindState(textEditorState.textState)
|
||||
var showFindBar by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(markdownConfig) {
|
||||
markdownExtension.updateMarkdownConfiguration(markdownConfig)
|
||||
LaunchedEffect(richTextStyles) {
|
||||
textEditorState.textState.updateRichTextStyles(richTextStyles)
|
||||
}
|
||||
|
||||
var hasReceivedInitialBuffer by remember { mutableStateOf(false) }
|
||||
|
|
@ -186,6 +190,11 @@ fun SceneEditorUi(
|
|||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.findShortcutModifier { showFindBar = true }
|
||||
.textSizeShortcutModifier(
|
||||
onIncrease = component::increaseTextSize,
|
||||
onDecrease = component::decreaseTextSize,
|
||||
onReset = component::resetTextSize,
|
||||
)
|
||||
.saveShortcutModifier { scope.launch { component.storeSceneContent() } }
|
||||
.markdownFormatShortcuts(markdownExtension)
|
||||
) {
|
||||
|
|
@ -237,9 +246,7 @@ fun SceneEditorUi(
|
|||
enabled = hasReceivedInitialBuffer,
|
||||
spellCheckMenuItems = spellCheckMenuItems,
|
||||
style = rememberTextEditorStyle(
|
||||
textStyle = TextStyle.Default.copy(
|
||||
textIndent = TextIndent(firstLine = 24.sp)
|
||||
),
|
||||
textStyle = LocalEditorTextStyle.current.withParagraphIndent(),
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
),
|
||||
|
|
|
|||
53
composeUi/src/desktopTest/kotlin/CycleHeaderTest.kt
Normal file
53
composeUi/src/desktopTest/kotlin/CycleHeaderTest.kt
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdowneditor.cycleHeader
|
||||
import com.darkrockstudios.texteditor.CharLineOffset
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
|
||||
import com.darkrockstudios.texteditor.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.state.TextEditorState
|
||||
import com.darkrockstudios.texteditor.state.headerLevel
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class CycleHeaderTest {
|
||||
|
||||
private fun TestScope.newEditor(): MarkdownExtension =
|
||||
TextEditorState(scope = this, measurer = mockk(relaxed = true)).withMarkdown(HammerMarkdownConfiguration)
|
||||
|
||||
@Test
|
||||
fun `cycling exports as a markdown heading`() = runTest {
|
||||
val editor = newEditor()
|
||||
editor.importMarkdown("Title\n\nBody")
|
||||
|
||||
cycleHeader(editor.editorState, currentLevel = 0)
|
||||
|
||||
assertEquals(1, editor.editorState.headerLevel(0))
|
||||
assertEquals("# Title", editor.exportAsMarkdown().lineSequence().first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cycling past the last level removes the heading`() = runTest {
|
||||
val editor = newEditor()
|
||||
editor.importMarkdown("### Title")
|
||||
|
||||
cycleHeader(editor.editorState, currentLevel = 3)
|
||||
|
||||
assertNull(editor.editorState.headerLevel(0))
|
||||
assertEquals("Title", editor.exportAsMarkdown().lineSequence().first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cycling past the last level clears a mixed selection`() = runTest {
|
||||
val editor = newEditor()
|
||||
editor.importMarkdown("### Title\nBody")
|
||||
editor.editorState.selector.updateSelection(CharLineOffset(0, 0), CharLineOffset(1, 4))
|
||||
|
||||
cycleHeader(editor.editorState, currentLevel = 3)
|
||||
|
||||
assertNull(editor.editorState.headerLevel(0))
|
||||
assertNull(editor.editorState.headerLevel(1))
|
||||
}
|
||||
}
|
||||
86
composeUi/src/desktopTest/kotlin/EditorTextSizeTest.kt
Normal file
86
composeUi/src/desktopTest/kotlin/EditorTextSizeTest.kt
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.withParagraphIndent
|
||||
import com.darkrockstudios.texteditor.TextEditor
|
||||
import com.darkrockstudios.texteditor.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.rememberTextEditorStyle
|
||||
import com.darkrockstudios.texteditor.state.TextEditorState
|
||||
import com.darkrockstudios.texteditor.state.rememberTextEditorState
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
private const val DOC =
|
||||
"# Title\n\n**bold** and *it* and [l](http://x.y) and `code`\n\n## Two\n\n> quote\n\n- a\n- b\n\n#### Four\n\nplain"
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class EditorTextSizeTest {
|
||||
|
||||
@Test
|
||||
fun `markdown round trips through the relative styles`() = runTest {
|
||||
val state = TextEditorState(scope = this, measurer = mockk(relaxed = true))
|
||||
state.richTextStyles = HammerRichTextStyles
|
||||
val markdown = state.withMarkdown(HammerMarkdownConfiguration)
|
||||
|
||||
markdown.importMarkdown(DOC)
|
||||
|
||||
assertEquals(DOC, markdown.exportAsMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the body size scales every line without touching the document`() {
|
||||
var bodySize by mutableStateOf(16f)
|
||||
var state: TextEditorState? = null
|
||||
runComposeUiTest {
|
||||
setContent {
|
||||
val editorState = rememberTextEditorState()
|
||||
state = editorState
|
||||
TextEditor(
|
||||
state = editorState,
|
||||
style = rememberTextEditorStyle(
|
||||
textStyle = TextStyle(fontSize = bodySize.sp).withParagraphIndent(),
|
||||
),
|
||||
)
|
||||
}
|
||||
waitForIdle()
|
||||
val editorState = state!!
|
||||
editorState.richTextStyles = HammerRichTextStyles
|
||||
editorState.withMarkdown(HammerMarkdownConfiguration).importMarkdown(DOC)
|
||||
waitForIdle()
|
||||
|
||||
fun lineHeights() = editorState.lineOffsets
|
||||
.filter { it.virtualLineIndex == 0 }
|
||||
.associate { it.line to it.textLayoutResult.size.height }
|
||||
fun indentOf(line: Int) = editorState.lineOffsets
|
||||
.first { it.line == line && it.virtualLineIndex == 0 }
|
||||
.textLayoutResult.getHorizontalPosition(0, usePrimaryDirection = true)
|
||||
|
||||
val small = lineHeights()
|
||||
val smallIndent = indentOf(small.keys.max())
|
||||
val linesBefore = editorState.textLines.toList()
|
||||
bodySize = 32f
|
||||
waitForIdle()
|
||||
val large = lineHeights()
|
||||
val largeIndent = indentOf(small.keys.max())
|
||||
|
||||
assertEquals(linesBefore, editorState.textLines.toList())
|
||||
assertEquals(24f, smallIndent)
|
||||
assertEquals(48f, largeIndent)
|
||||
val lastLine = small.keys.max()
|
||||
assertTrue(small.getValue(0) > small.getValue(lastLine) * 1.6f)
|
||||
small.keys.forEach { line ->
|
||||
val ratio = large.getValue(line).toFloat() / small.getValue(line)
|
||||
assertTrue(ratio in 1.8f..2.2f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import androidx.compose.ui.unit.sp
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
|
||||
import com.darkrockstudios.texteditor.state.TextEditorState
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class MarkdownConfigRestyleTest {
|
||||
|
||||
private fun TestScope.newEditor(config: MarkdownConfiguration): MarkdownExtension {
|
||||
val state = TextEditorState(scope = this, measurer = mockk(relaxed = true))
|
||||
return MarkdownExtension(state, config)
|
||||
}
|
||||
|
||||
private fun configWithBodySize(size: Float) = MarkdownConfiguration.DEFAULT.copy(
|
||||
defaultTextStyle = MarkdownConfiguration.DEFAULT.defaultTextStyle.copy(fontSize = size.sp),
|
||||
header1Style = MarkdownConfiguration.DEFAULT.header1Style.copy(fontSize = (size * 2).sp),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `stepping the font size leaves one baked style per heading`() = runTest {
|
||||
val editor = newEditor(configWithBodySize(16f))
|
||||
editor.importMarkdown("# Chapter One\n\nSome body text.")
|
||||
val before = editor.editorState.textLines[0].spanStyles.size
|
||||
|
||||
repeat(5) { step -> editor.updateMarkdownConfiguration(configWithBodySize(18f + step * 2)) }
|
||||
|
||||
assertEquals(before, editor.editorState.textLines[0].spanStyles.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `heading tracks the new font size`() = runTest {
|
||||
val editor = newEditor(configWithBodySize(16f))
|
||||
editor.importMarkdown("# Chapter One")
|
||||
|
||||
editor.updateMarkdownConfiguration(configWithBodySize(20f))
|
||||
|
||||
val sizes = editor.editorState.textLines[0].spanStyles.map { it.item.fontSize }
|
||||
assertEquals(listOf(40f.sp), sizes.filter { it != 20f.sp })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import androidx.compose.ui.unit.sp
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.changeFontSize
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateMarkdownConfiguration
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStylesDark
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
|
||||
import com.darkrockstudios.texteditor.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.state.TextEditorState
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
|
|
@ -11,34 +12,25 @@ import org.junit.Test
|
|||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Changing the editor font size restyles every line in place, which must not cost
|
||||
* block lines the ParagraphStyle their gutter markers are positioned against.
|
||||
* A theme change restyles every line in place, which must not cost block lines the
|
||||
* ParagraphStyle their gutter markers are positioned against.
|
||||
*/
|
||||
class MarkdownRestyleBlockIndentTest {
|
||||
|
||||
private fun TestScope.newEditor(config: MarkdownConfiguration): MarkdownExtension =
|
||||
MarkdownExtension(TextEditorState(scope = this, measurer = mockk(relaxed = true)), config)
|
||||
private fun TestScope.newEditor(): MarkdownExtension {
|
||||
val state = TextEditorState(scope = this, measurer = mockk(relaxed = true))
|
||||
state.richTextStyles = HammerRichTextStyles
|
||||
return state.withMarkdown(HammerMarkdownConfiguration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list indents survive a font size change`() = runTest {
|
||||
val editor = newEditor(MarkdownConfiguration.DEFAULT)
|
||||
fun `list indents survive a theme change`() = runTest {
|
||||
val editor = newEditor()
|
||||
editor.importMarkdown("A paragraph.\n\n1. one\n2. two\n\n- bullet\n\n> quoted")
|
||||
val before = editor.editorState.textLines.map { it.paragraphStyles }
|
||||
|
||||
editor.updateMarkdownConfiguration(MarkdownConfiguration.DEFAULT.changeFontSize(24f))
|
||||
editor.editorState.updateRichTextStyles(HammerRichTextStylesDark)
|
||||
|
||||
assertEquals(before, editor.editorState.textLines.map { it.paragraphStyles })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `body text tracks the new font size`() = runTest {
|
||||
val editor = newEditor(MarkdownConfiguration.DEFAULT)
|
||||
editor.importMarkdown("A paragraph.\n\n1. one")
|
||||
|
||||
editor.updateMarkdownConfiguration(MarkdownConfiguration.DEFAULT.changeFontSize(24f))
|
||||
|
||||
editor.editorState.textLines.filter { it.text.isNotEmpty() }.forEach { line ->
|
||||
assertEquals(listOf(24f.sp), line.spanStyles.map { it.item.fontSize })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStyles
|
||||
import com.darkrockstudios.apps.hammer.common.compose.HammerRichTextStylesDark
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.updateRichTextStyles
|
||||
import com.darkrockstudios.texteditor.RichTextStyles
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
|
||||
import com.darkrockstudios.texteditor.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.state.TextEditorState
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Switching themes restyles the document in place; the body size never does (see EditorTextSizeTest). */
|
||||
class RichTextStylesRestyleTest {
|
||||
|
||||
private fun TestScope.newEditor(styles: RichTextStyles): MarkdownExtension {
|
||||
val state = TextEditorState(scope = this, measurer = mockk(relaxed = true))
|
||||
state.richTextStyles = styles
|
||||
return state.withMarkdown(HammerMarkdownConfiguration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching themes leaves one baked style per heading`() = runTest {
|
||||
val editor = newEditor(HammerRichTextStyles)
|
||||
editor.importMarkdown("# Chapter One\n\nSome body text.")
|
||||
val before = editor.editorState.textLines[0].spanStyles.size
|
||||
|
||||
repeat(5) { step ->
|
||||
editor.editorState.updateRichTextStyles(
|
||||
if (step % 2 == 0) HammerRichTextStylesDark else HammerRichTextStyles
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(before, editor.editorState.textLines[0].spanStyles.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `code spans take the new theme's background`() = runTest {
|
||||
val editor = newEditor(HammerRichTextStyles)
|
||||
editor.importMarkdown("Some `code` here.")
|
||||
|
||||
editor.editorState.updateRichTextStyles(HammerRichTextStylesDark)
|
||||
|
||||
val backgrounds = editor.editorState.textLines[0].spanStyles.map { it.item.background }
|
||||
assertTrue(HammerRichTextStylesDark.codeStyle.background in backgrounds)
|
||||
assertTrue(HammerRichTextStyles.codeStyle.background !in backgrounds)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import com.darkrockstudios.apps.hammer.common.compose.ComposeRichText
|
||||
import com.darkrockstudios.apps.hammer.common.compose.markdown.HammerMarkdownConfiguration
|
||||
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
|
||||
import com.darkrockstudios.apps.hammer.common.data.SceneContent
|
||||
import com.darkrockstudios.apps.hammer.common.data.SceneItem
|
||||
import com.darkrockstudios.apps.hammer.common.fileio.HPath
|
||||
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.loadSceneContent
|
||||
import com.darkrockstudios.apps.hammer.common.storyeditor.sceneeditor.sceneDiffText
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownConfiguration
|
||||
import com.darkrockstudios.texteditor.markdown.MarkdownExtension
|
||||
import com.darkrockstudios.texteditor.markdown.withMarkdown
|
||||
import com.darkrockstudios.texteditor.richstyle.HorizontalRuleSpanStyle
|
||||
import com.darkrockstudios.texteditor.state.TextEditorState
|
||||
import io.mockk.mockk
|
||||
|
|
@ -33,7 +34,7 @@ class SceneContentLoadTest {
|
|||
|
||||
private fun TestScope.newEditor(): MarkdownExtension {
|
||||
val state = TextEditorState(scope = this, measurer = mockk(relaxed = true))
|
||||
return MarkdownExtension(state, MarkdownConfiguration.DEFAULT)
|
||||
return state.withMarkdown(HammerMarkdownConfiguration)
|
||||
}
|
||||
|
||||
private fun MarkdownExtension.horizontalRuleLines(): List<Int> =
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ korge-core = "6.0.0"
|
|||
bouncycastle = "1.86"
|
||||
kotlinx-io-okio = "0.9.1"
|
||||
|
||||
compose-texteditor = "2.8.0"
|
||||
compose-texteditor = "3.0.2"
|
||||
kotlin-multiplatform-diff = "1.4.0"
|
||||
platform-spellcheckerkt = "1.3.2"
|
||||
nucleus = "2.5.18"
|
||||
|
|
@ -344,6 +344,7 @@ moko-permissions-storage = { group = "dev.icerock.moko", name = "permissions-sto
|
|||
moko-permissions-compose = { group = "dev.icerock.moko", name = "permissions-compose", version.ref = "moko_permissions" }
|
||||
|
||||
compose-texteditor = { group = "com.darkrockstudios", name = "composetexteditor", version.ref = "compose-texteditor" }
|
||||
compose-texteditor-markdown = { group = "com.darkrockstudios", name = "composetexteditor-markdown", version.ref = "compose-texteditor" }
|
||||
compose-texteditor-find = { group = "com.darkrockstudios", name = "composetexteditor-find", version.ref = "compose-texteditor" }
|
||||
compose-texteditor-spellcheck = { group = "com.darkrockstudios", name = "composetexteditor-spellcheck", version.ref = "compose-texteditor" }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue