board: a tag in the prose is coloured where it sits, not printed twice
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
A tagged note was showing its tag twice — once where it was typed, once as a chip — and the duplicate was the loud copy. Now the chip row carries only what the body cannot say (a tag lifted off its own line, a label from the picker), and a `#tag` left mid-sentence is tinted in place. Which characters are a tag is asked of the CORE, the way the card already asks it which lines are checklist items: `extract_tag_spans` keeps the spans `extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are UTF-16 code units, because `AnnotatedString` and JS both index that way and a char index lands mid-token the first time somebody writes an emoji. The web keeps its own matcher in markdown.ts, mirroring `line_tags` case for case. The inline ink is its own table, one Tailwind step deeper than the chip's. A chip brings its own -100 fill and reads against that alone; inline text sits on whatever the card is, including a gray-tagged card at neutral-200 — where the chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5 body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84 dark across every palette and generated fill. Chips now carry the `#` on every surface. The via_tag branch that used to decide it is gone from the card, and Android's row said no hash at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -336,7 +336,10 @@ fun EditorLabelRow(
|
|||||||
modifier = Modifier.padding(vertical = 2.dp),
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label.name,
|
// `#` on every chip, matching the card. This row still shows the
|
||||||
|
// tags the BODY owns as well — it is the control surface, and the
|
||||||
|
// "from tag" hint beside one is what says why it has no cross.
|
||||||
|
text = "#${label.name}",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = tint.chipForeground(dark),
|
color = tint.chipForeground(dark),
|
||||||
modifier =
|
modifier =
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -30,6 +34,7 @@ import com.fabledsword.thoughtsync.R
|
|||||||
import com.fabledsword.thoughtsync.core.BodyItem
|
import com.fabledsword.thoughtsync.core.BodyItem
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
import com.fabledsword.thoughtsync.core.Note
|
||||||
import com.fabledsword.thoughtsync.core.NoteLabel
|
import com.fabledsword.thoughtsync.core.NoteLabel
|
||||||
|
import com.fabledsword.thoughtsync.core.bodyTags
|
||||||
import com.fabledsword.thoughtsync.core.checklistItems
|
import com.fabledsword.thoughtsync.core.checklistItems
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -67,8 +72,16 @@ fun NoteCard(
|
|||||||
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete
|
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete
|
||||||
// with the thing that identifies the note. A row of its own costs one line and
|
// with the thing that identifies the note. A row of its own costs one line and
|
||||||
// only on notes that have tags at all.
|
// only on notes that have tags at all.
|
||||||
if (note.labels.isNotEmpty()) {
|
//
|
||||||
LabelChips(labels = note.labels)
|
// ONLY the labels whose text is not still in the note. `via_tag` means exactly
|
||||||
|
// "backed by body text" since M311, so a chip for one printed the same tag
|
||||||
|
// twice — once where it was typed, once up here — and the card was carrying
|
||||||
|
// furniture for information it was already showing. A tag left in prose is
|
||||||
|
// tinted in place instead; see [tintTags]. What reaches this row is what the
|
||||||
|
// body cannot say: a tag lifted off its own line, and a label added by hand.
|
||||||
|
val chips = note.labels.filterNot { it.viaTag }
|
||||||
|
if (chips.isNotEmpty()) {
|
||||||
|
LabelChips(labels = chips)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,13 +142,13 @@ private fun NoteBody(
|
|||||||
val found = itemAtLine[n]
|
val found = itemAtLine[n]
|
||||||
when {
|
when {
|
||||||
found != null ->
|
found != null ->
|
||||||
ChecklistRow(found.second) { onToggleItem(found.first, !found.second.checked) }
|
ChecklistRow(note, found.second) { onToggleItem(found.first, !found.second.checked) }
|
||||||
// Kept as a gap rather than dropped: it is the paragraph break
|
// Kept as a gap rather than dropped: it is the paragraph break
|
||||||
// somebody typed, and the card reads as a wall without it.
|
// somebody typed, and the card reads as a wall without it.
|
||||||
line.isBlank() -> Spacer(Modifier.height(4.dp))
|
line.isBlank() -> Spacer(Modifier.height(4.dp))
|
||||||
else ->
|
else ->
|
||||||
Text(
|
Text(
|
||||||
text = line,
|
text = tintTags(line, note),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = MAX_WRAPPED_LINES,
|
maxLines = MAX_WRAPPED_LINES,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -163,6 +176,7 @@ private fun NoteBody(
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChecklistRow(
|
private fun ChecklistRow(
|
||||||
|
note: Note,
|
||||||
item: BodyItem,
|
item: BodyItem,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -176,7 +190,7 @@ private fun ChecklistRow(
|
|||||||
.padding(end = 6.dp),
|
.padding(end = 6.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item.text,
|
text = tintTags(item.text, note),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
||||||
color =
|
color =
|
||||||
@@ -191,6 +205,58 @@ private fun ChecklistRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One string of a note's own words, with every `#tag` in it drawn in that tag's colour.
|
||||||
|
*
|
||||||
|
* This is what replaced the chip for a tag still living in the prose. The card used to
|
||||||
|
* print such a tag twice — once where it was typed and once in the row above — and the
|
||||||
|
* duplicate was the loud copy, which made a tagged note read as "tag, then some text
|
||||||
|
* that happens to start with the same word". Colouring it in place says the same thing
|
||||||
|
* with no furniture, and says it more honestly: the token you can see IS the text you
|
||||||
|
* would delete to remove the tag.
|
||||||
|
*
|
||||||
|
* WHICH characters are a tag is asked of the core, exactly as [NoteBody] asks it which
|
||||||
|
* lines are checklist items. The grammar already exists three times (Rust, Python,
|
||||||
|
* TypeScript); a fourth in Compose would be a fourth thing to disagree — and this one
|
||||||
|
* would fail silently, as the wrong characters tinted rather than an error anywhere.
|
||||||
|
* The core's offsets are UTF-16 code units for this call site specifically, which is
|
||||||
|
* the only unit `addStyle` can take.
|
||||||
|
*
|
||||||
|
* Called per rendered STRING rather than once per body so a checklist item's text can
|
||||||
|
* be handled with no arithmetic: an item is a line minus a `- [ ] ` prefix of a length
|
||||||
|
* nothing carries, and shifting spans by a guessed prefix is the kind of off-by-one
|
||||||
|
* that shows up only on the one note that had a tag in a list.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun tintTags(
|
||||||
|
text: String,
|
||||||
|
note: Note,
|
||||||
|
): AnnotatedString {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
return remember(text, note.labels, dark) {
|
||||||
|
val spans = bodyTags(text)
|
||||||
|
if (spans.isEmpty()) {
|
||||||
|
AnnotatedString(text)
|
||||||
|
} else {
|
||||||
|
// A tag the note does not carry as a label yet — just typed, not yet
|
||||||
|
// derived — still gets a colour: `labelTint` falls back to deriving one
|
||||||
|
// from the name, which is what the chip would have shown anyway.
|
||||||
|
val picked = note.labels.associate { it.name.lowercase() to it.color }
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
spans.forEach { tag ->
|
||||||
|
val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty())
|
||||||
|
addStyle(
|
||||||
|
SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium),
|
||||||
|
tag.start.toInt(),
|
||||||
|
tag.end.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LabelChips(labels: List<NoteLabel>) {
|
private fun LabelChips(labels: List<NoteLabel>) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -200,7 +266,11 @@ private fun LabelChips(labels: List<NoteLabel>) {
|
|||||||
labels.take(MAX_LABEL_CHIPS).forEach { label ->
|
labels.take(MAX_LABEL_CHIPS).forEach { label ->
|
||||||
val tint = labelTintFor(label.name, label.color)
|
val tint = labelTintFor(label.name, label.color)
|
||||||
Text(
|
Text(
|
||||||
text = label.name,
|
// The `#` is carried on every chip, because everything that reaches
|
||||||
|
// this row is a tag — a tag lifted off its own line, or one attached
|
||||||
|
// through the picker — and the hash is how you would type either. It
|
||||||
|
// also keeps a lifted chip reading as the `#todo` somebody wrote.
|
||||||
|
text = "#${label.name}",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = tint.chipForeground(dark),
|
color = tint.chipForeground(dark),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ data class NoteTint(
|
|||||||
val lightChipForeground: Color,
|
val lightChipForeground: Color,
|
||||||
val darkChipBackground: Color,
|
val darkChipBackground: Color,
|
||||||
val darkChipForeground: Color,
|
val darkChipForeground: Color,
|
||||||
|
/**
|
||||||
|
* The colour a `#tag` is drawn in when it is left in the prose — see [tagInk].
|
||||||
|
*
|
||||||
|
* A SEPARATE value from [lightChipForeground] rather than a reuse of it, and the
|
||||||
|
* difference is not cosmetic. A chip brings its own `-100` fill, so its text only
|
||||||
|
* ever has to read against that one colour. Inline text sits on whatever the card
|
||||||
|
* happens to be, which includes the gray-tagged card at `neutral-200` — and there
|
||||||
|
* the chip's `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all
|
||||||
|
* under the 4.5 body text needs. One step deeper puts every hue between 5.63 and
|
||||||
|
* 12.01 in light and 7.20 and 10.84 in dark, across every fill in the palette and
|
||||||
|
* every generated fill, so it is one rule rather than three exceptions.
|
||||||
|
*/
|
||||||
|
val lightTagInk: Color,
|
||||||
|
val darkTagInk: Color,
|
||||||
/**
|
/**
|
||||||
* False only for `default`, which is the ABSENCE of a colour rather than one of
|
* False only for `default`, which is the ABSENCE of a colour rather than one of
|
||||||
* them. A tint can be drawn at the chosen weight (see [chosenBackground]);
|
* them. A tint can be drawn at the chosen weight (see [chosenBackground]);
|
||||||
@@ -81,6 +95,16 @@ data class NoteTint(
|
|||||||
|
|
||||||
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
|
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour for a `#tag` still sitting in the note's own words.
|
||||||
|
*
|
||||||
|
* A tag whose text is in the body is no longer repeated as a chip (the card was
|
||||||
|
* printing every tag twice — once where it was typed, once at the top). It is
|
||||||
|
* tinted in place instead, which is both less furniture and a more honest card:
|
||||||
|
* the thing you see IS the thing you would delete to remove the tag.
|
||||||
|
*/
|
||||||
|
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A hairline edge for a chip, in its own text colour at low alpha.
|
* A hairline edge for a chip, in its own text colour at low alpha.
|
||||||
*
|
*
|
||||||
@@ -128,6 +152,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
|||||||
lightChipForeground = Color(0xFF525252),
|
lightChipForeground = Color(0xFF525252),
|
||||||
darkChipBackground = Color(0x1AFFFFFF),
|
darkChipBackground = Color(0x1AFFFFFF),
|
||||||
darkChipForeground = Color(0xFFD4D4D4),
|
darkChipForeground = Color(0xFFD4D4D4),
|
||||||
|
lightTagInk = Color(0xFF404040),
|
||||||
|
darkTagInk = Color(0xFFD4D4D4),
|
||||||
tintable = false,
|
tintable = false,
|
||||||
),
|
),
|
||||||
"red" to
|
"red" to
|
||||||
@@ -141,6 +167,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
|||||||
lightChipForeground = Color(0xFFB91C1C),
|
lightChipForeground = Color(0xFFB91C1C),
|
||||||
darkChipBackground = Color(0x80450A0A),
|
darkChipBackground = Color(0x80450A0A),
|
||||||
darkChipForeground = Color(0xFFFCA5A5),
|
darkChipForeground = Color(0xFFFCA5A5),
|
||||||
|
lightTagInk = Color(0xFF991B1B),
|
||||||
|
darkTagInk = Color(0xFFFCA5A5),
|
||||||
),
|
),
|
||||||
"orange" to
|
"orange" to
|
||||||
NoteTint(
|
NoteTint(
|
||||||
@@ -153,6 +181,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
|||||||
lightChipForeground = Color(0xFFC2410C),
|
lightChipForeground = Color(0xFFC2410C),
|
||||||
darkChipBackground = Color(0x80431407),
|
darkChipBackground = Color(0x80431407),
|
||||||
darkChipForeground = Color(0xFFFDBA74),
|
darkChipForeground = Color(0xFFFDBA74),
|
||||||
|
lightTagInk = Color(0xFF9A3412),
|
||||||
|
darkTagInk = Color(0xFFFDBA74),
|
||||||
),
|
),
|
||||||
"yellow" to
|
"yellow" to
|
||||||
NoteTint(
|
NoteTint(
|
||||||
@@ -165,6 +195,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
|||||||
lightChipForeground = Color(0xFF92400E),
|
lightChipForeground = Color(0xFF92400E),
|
||||||
darkChipBackground = Color(0x80451A03),
|
darkChipBackground = Color(0x80451A03),
|
||||||
darkChipForeground = Color(0xFFFCD34D),
|
darkChipForeground = Color(0xFFFCD34D),
|
||||||
|
lightTagInk = Color(0xFF92400E),
|
||||||
|
darkTagInk = Color(0xFFFCD34D),
|
||||||
),
|
),
|
||||||
"green" to
|
"green" to
|
||||||
NoteTint(
|
NoteTint(
|
||||||
@@ -177,6 +209,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
|||||||
lightChipForeground = Color(0xFF15803D),
|
lightChipForeground = Color(0xFF15803D),
|
||||||
darkChipBackground = Color(0x80052E16),
|
darkChipBackground = Color(0x80052E16),
|
||||||
darkChipForeground = Color(0xFF86EFAC),
|
darkChipForeground = Color(0xFF86EFAC),
|
||||||
|
lightTagInk = Color(0xFF166534),
|
||||||
|
darkTagInk = Color(0xFF86EFAC),
|
||||||
),
|
),
|
||||||
"teal" to
|
"teal" to
|
||||||
NoteTint(
|
NoteTint(
|
||||||
@@ -189,6 +223,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
|||||||
lightChipForeground = Color(0xFF0F766E),
|
lightChipForeground = Color(0xFF0F766E),
|
||||||
darkChipBackground = Color(0x80042F2E),
|
darkChipBackground = Color(0x80042F2E),
|
||||||
darkChipForeground = Color(0xFF5EEAD4),
|
darkChipForeground = Color(0xFF5EEAD4),
|
||||||
|
lightTagInk = Color(0xFF115E59),
|
||||||
|
darkTagInk = Color(0xFF5EEAD4),
|
||||||
),
|
),
|
||||||
"blue" to
|
"blue" to
|
||||||
NoteTint(
|
NoteTint(
|
||||||
@@ -201,6 +237,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
|||||||
lightChipForeground = Color(0xFF1D4ED8),
|
lightChipForeground = Color(0xFF1D4ED8),
|
||||||
darkChipBackground = Color(0x80172554),
|
darkChipBackground = Color(0x80172554),
|
||||||
darkChipForeground = Color(0xFF93C5FD),
|
darkChipForeground = Color(0xFF93C5FD),
|
||||||
|
lightTagInk = Color(0xFF1E40AF),
|
||||||
|
darkTagInk = Color(0xFF93C5FD),
|
||||||
),
|
),
|
||||||
"purple" to
|
"purple" to
|
||||||
NoteTint(
|
NoteTint(
|
||||||
@@ -213,6 +251,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
|||||||
lightChipForeground = Color(0xFF7E22CE),
|
lightChipForeground = Color(0xFF7E22CE),
|
||||||
darkChipBackground = Color(0x803B0764),
|
darkChipBackground = Color(0x803B0764),
|
||||||
darkChipForeground = Color(0xFFD8B4FE),
|
darkChipForeground = Color(0xFFD8B4FE),
|
||||||
|
lightTagInk = Color(0xFF6B21A8),
|
||||||
|
darkTagInk = Color(0xFFD8B4FE),
|
||||||
),
|
),
|
||||||
"pink" to
|
"pink" to
|
||||||
NoteTint(
|
NoteTint(
|
||||||
@@ -225,6 +265,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
|||||||
lightChipForeground = Color(0xFFBE185D),
|
lightChipForeground = Color(0xFFBE185D),
|
||||||
darkChipBackground = Color(0x80500724),
|
darkChipBackground = Color(0x80500724),
|
||||||
darkChipForeground = Color(0xFFF9A8D4),
|
darkChipForeground = Color(0xFFF9A8D4),
|
||||||
|
lightTagInk = Color(0xFF9D174D),
|
||||||
|
darkTagInk = Color(0xFFF9A8D4),
|
||||||
),
|
),
|
||||||
"gray" to
|
"gray" to
|
||||||
NoteTint(
|
NoteTint(
|
||||||
@@ -237,6 +279,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
|||||||
lightChipForeground = Color(0xFF404040),
|
lightChipForeground = Color(0xFF404040),
|
||||||
darkChipBackground = Color(0xFF404040),
|
darkChipBackground = Color(0xFF404040),
|
||||||
darkChipForeground = Color(0xFFE5E5E5),
|
darkChipForeground = Color(0xFFE5E5E5),
|
||||||
|
lightTagInk = Color(0xFF262626),
|
||||||
|
darkTagInk = Color(0xFFE5E5E5),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -321,4 +365,17 @@ private fun firstLabelColor(note: Note): String {
|
|||||||
fun labelTintFor(
|
fun labelTintFor(
|
||||||
name: String,
|
name: String,
|
||||||
color: String,
|
color: String,
|
||||||
): NoteTint = noteTint(resolvedLabelColor(name, color, NOTE_TINTS.keys))
|
): NoteTint = labelTint(name, color)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [labelTintFor] with no composable context, for a caller building its value inside
|
||||||
|
* `remember` — where a `@Composable` call is not allowed. The card's inline tag
|
||||||
|
* colours are computed there, once per body rather than once per recomposition.
|
||||||
|
*
|
||||||
|
* One implementation, two entry points: the composable one delegates here rather than
|
||||||
|
* repeating the lookup, so the chip and the inline token cannot resolve differently.
|
||||||
|
*/
|
||||||
|
fun labelTint(
|
||||||
|
name: String,
|
||||||
|
color: String,
|
||||||
|
): NoteTint = NOTE_TINTS[resolvedLabelColor(name, color, NOTE_TINTS.keys)] ?: NOTE_TINTS.getValue("default")
|
||||||
|
|||||||
+17
-2
@@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore;
|
|||||||
use thoughtsync_core::sync::{client, compat, engine, push, state};
|
use thoughtsync_core::sync::{client, compat, engine, push, state};
|
||||||
|
|
||||||
use models::{
|
use models::{
|
||||||
patch_from, BodyItem, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery,
|
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit,
|
||||||
ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
|
NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
@@ -530,6 +530,21 @@ pub fn checklist_items(body: String) -> Vec<BodyItem> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every `#tag` in a body, with the line and the UTF-16 span each one occupies — so
|
||||||
|
/// a card can colour the tag where it was typed instead of printing it twice.
|
||||||
|
///
|
||||||
|
/// The same argument as `checklist_items` above, and the same answer: the grammar for
|
||||||
|
/// what a `#tag` is already exists in Rust, Python and TypeScript. Matching it a
|
||||||
|
/// fourth time in Compose would be a fourth place for a tag to change shape when it
|
||||||
|
/// syncs — and this one would fail silently, as the wrong characters tinted.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn body_tags(body: String) -> Vec<BodyTag> {
|
||||||
|
local::derive::extract_tag_spans(&body)
|
||||||
|
.into_iter()
|
||||||
|
.map(BodyTag::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
||||||
/// block names, so these stay Rust-side.
|
/// block names, so these stay Rust-side.
|
||||||
impl ThoughtSync {
|
impl ThoughtSync {
|
||||||
|
|||||||
@@ -76,6 +76,36 @@ impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One `#tag` and where it sits in a note's body.
|
||||||
|
///
|
||||||
|
/// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather
|
||||||
|
/// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16
|
||||||
|
/// code units precisely because Kotlin's `AnnotatedString` counts that way.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct BodyTag {
|
||||||
|
pub line: u32,
|
||||||
|
pub start: u32,
|
||||||
|
pub end: u32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<thoughtsync_core::local::derive::DerivedTag> for BodyTag {
|
||||||
|
fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self {
|
||||||
|
let thoughtsync_core::local::derive::DerivedTag {
|
||||||
|
line,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
name,
|
||||||
|
} = t;
|
||||||
|
BodyTag {
|
||||||
|
line,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An Android build the linked server is offering, already judged to be newer.
|
/// An Android build the linked server is offering, already judged to be newer.
|
||||||
///
|
///
|
||||||
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
|
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
|
||||||
|
|||||||
@@ -62,6 +62,62 @@ pub fn extract_tags(body: &str) -> Vec<String> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
|
||||||
|
///
|
||||||
|
/// The card no longer prints a chip for a tag whose text is still in the note — it
|
||||||
|
/// colours the token where it was typed instead. To do that a renderer needs the
|
||||||
|
/// SPAN, not just the name, and asking it to find the name again would be a second
|
||||||
|
/// grammar quietly disagreeing with this one about what `##a` or `#1` is.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DerivedTag {
|
||||||
|
/// Which body line it sits on, like [`DerivedItem::line`].
|
||||||
|
pub line: u32,
|
||||||
|
/// Offsets into that line, in UTF-16 code units — INCLUDING the leading `#`.
|
||||||
|
///
|
||||||
|
/// UTF-16 rather than chars or bytes because the two languages that consume this
|
||||||
|
/// both index strings that way: Kotlin's `AnnotatedString` and JavaScript. A char
|
||||||
|
/// index is right up until somebody puts an emoji before a tag, and then it lands
|
||||||
|
/// mid-token with no error anywhere.
|
||||||
|
pub start: u32,
|
||||||
|
pub end: u32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `#tag` in `body` with its position — the same scan [`extract_tags`] does,
|
||||||
|
/// keeping the spans instead of throwing them away.
|
||||||
|
///
|
||||||
|
/// Not deduped, unlike `extract_tags`: two mentions of `#todo` are two pieces of text
|
||||||
|
/// to colour. Fences are not skipped either, and that is deliberate — `extract_tags`
|
||||||
|
/// does not skip them, so a `#tag` inside a code block IS a label on the note, and a
|
||||||
|
/// renderer that left it plain would be the only surface disagreeing.
|
||||||
|
pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (n, line) in body.split('\n').enumerate() {
|
||||||
|
let chars: Vec<char> = line.chars().collect();
|
||||||
|
let spans = line_tags(&chars);
|
||||||
|
if spans.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Prefix sums, built once per tagged line: char index -> UTF-16 offset.
|
||||||
|
let mut units: Vec<u32> = Vec::with_capacity(chars.len() + 1);
|
||||||
|
let mut total: u32 = 0;
|
||||||
|
units.push(0);
|
||||||
|
for c in &chars {
|
||||||
|
total += c.len_utf16() as u32;
|
||||||
|
units.push(total);
|
||||||
|
}
|
||||||
|
for (start, end, name) in spans {
|
||||||
|
out.push(DerivedTag {
|
||||||
|
line: n as u32,
|
||||||
|
start: units[start],
|
||||||
|
end: units[end],
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a line opens or closes a fenced code block.
|
/// Whether a line opens or closes a fenced code block.
|
||||||
fn is_fence(line: &str) -> bool {
|
fn is_fence(line: &str) -> bool {
|
||||||
let trimmed = line.trim_start();
|
let trimmed = line.trim_start();
|
||||||
@@ -438,6 +494,36 @@ mod tests {
|
|||||||
assert!(extract_tags("").is_empty());
|
assert!(extract_tags("").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── tag spans, for the renderer that draws them in place ─────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_spans_carry_the_hash_and_the_line() {
|
||||||
|
let spans = extract_tag_spans("buy milk #grocery\nand call #mom about #mom");
|
||||||
|
assert_eq!(spans.len(), 3);
|
||||||
|
assert_eq!((spans[0].line, spans[0].start, spans[0].end), (0, 9, 17));
|
||||||
|
assert_eq!(spans[0].name, "grocery");
|
||||||
|
// Not deduped: two mentions are two pieces of text to colour.
|
||||||
|
assert_eq!(spans[1].line, 1);
|
||||||
|
assert_eq!(spans[2].name, "mom");
|
||||||
|
assert_eq!((spans[2].start, spans[2].end), (20, 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_spans_are_utf16_offsets_not_char_indices() {
|
||||||
|
// The emoji is ONE char and TWO UTF-16 code units. Kotlin and JS both index
|
||||||
|
// the second way, so a char index would highlight one character too early.
|
||||||
|
let spans = extract_tag_spans("🎁 #gift");
|
||||||
|
assert_eq!(spans.len(), 1);
|
||||||
|
assert_eq!((spans[0].start, spans[0].end), (3, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_spans_agree_with_extract_tags_about_what_a_tag_is() {
|
||||||
|
let body = "#1 nope a#b no but #Yes ##no";
|
||||||
|
let names: Vec<String> = extract_tag_spans(body).into_iter().map(|t| t.name).collect();
|
||||||
|
assert_eq!(names, extract_tags(body));
|
||||||
|
}
|
||||||
|
|
||||||
// ── lifting standalone tags ──────────────────────────────────────────────
|
// ── lifting standalone tags ──────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
|
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { InlineToken } from "../notes/markdown";
|
import type { InlineToken } from "../notes/markdown";
|
||||||
|
import { tagTextClasses } from "../notes/colors";
|
||||||
|
|
||||||
// Emphasis and code only. `[[wiki-links]]` were the one token type that needed a
|
// Emphasis, code, and `#tags`. `[[wiki-links]]` were the one token type that needed a
|
||||||
// router, a store and a resolver behind it; they are gone (note 2897), and so is all
|
// router, a store and a resolver behind it; they are gone (note 2897), and so is all
|
||||||
// of that.
|
// of that.
|
||||||
defineProps<{ tokens: InlineToken[] }>();
|
//
|
||||||
|
// `tagColors` maps a lowercased tag name to the colour stored on that label. Threaded
|
||||||
|
// down from the card rather than looked up here, because this component renders text
|
||||||
|
// and has no idea which note the text belongs to — and a tag the operator recoloured
|
||||||
|
// must read the same here as it does on a chip.
|
||||||
|
defineProps<{ tokens: InlineToken[]; tagColors?: Record<string, string> }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
|
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
|
||||||
@@ -17,6 +23,8 @@ defineProps<{ tokens: InlineToken[] }>();
|
|||||||
v-else-if="t.type === 'code'"
|
v-else-if="t.type === 'code'"
|
||||||
class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10"
|
class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10"
|
||||||
>{{ t.value }}</code
|
>{{ t.value }}</code
|
||||||
|
><span v-else-if="t.type === 'tag'" class="font-medium" :class="tagTextClasses(t.value, tagColors)"
|
||||||
|
>#{{ t.value }}</span
|
||||||
><template v-else>{{ t.value }}</template></template
|
><template v-else>{{ t.value }}</template></template
|
||||||
></template
|
></template
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { computed } from "vue";
|
|||||||
import { parseMarkdown } from "../notes/markdown";
|
import { parseMarkdown } from "../notes/markdown";
|
||||||
import MarkdownInline from "./MarkdownInline.vue";
|
import MarkdownInline from "./MarkdownInline.vue";
|
||||||
|
|
||||||
const props = defineProps<{ text: string; toggleable?: boolean }>();
|
// `tagColors` is passed straight through to MarkdownInline — see there for why the
|
||||||
|
// card owns the lookup rather than the renderer.
|
||||||
|
const props = defineProps<{
|
||||||
|
text: string;
|
||||||
|
toggleable?: boolean;
|
||||||
|
tagColors?: Record<string, string>;
|
||||||
|
}>();
|
||||||
// Ticking a box rewrites a line of the note's body, which is a thing only the owner
|
// Ticking a box rewrites a line of the note's body, which is a thing only the owner
|
||||||
// of that note can do — so this renders the checkbox and hands the intent up rather
|
// of that note can do — so this renders the checkbox and hands the intent up rather
|
||||||
// than reaching for the store itself. The card wires it; a read-only render does not
|
// than reaching for the store itself. The card wires it; a read-only render does not
|
||||||
@@ -15,14 +21,20 @@ const blocks = computed(() => parseMarkdown(props.text));
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-1.5 break-words">
|
<div class="space-y-1.5 break-words">
|
||||||
<template v-for="(b, i) in blocks" :key="i">
|
<template v-for="(b, i) in blocks" :key="i">
|
||||||
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h3>
|
<h3 v-if="b.type === 'h1'" class="text-base font-bold">
|
||||||
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4>
|
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
|
||||||
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
|
</h3>
|
||||||
|
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold">
|
||||||
|
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
|
||||||
|
</h4>
|
||||||
|
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold">
|
||||||
|
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
|
||||||
|
</h5>
|
||||||
<blockquote
|
<blockquote
|
||||||
v-else-if="b.type === 'quote'"
|
v-else-if="b.type === 'quote'"
|
||||||
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
|
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
|
||||||
>
|
>
|
||||||
<MarkdownInline :tokens="b.inline ?? []" />
|
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
|
||||||
</blockquote>
|
</blockquote>
|
||||||
<div v-else-if="b.type === 'task'" class="flex flex-col gap-1">
|
<div v-else-if="b.type === 'task'" class="flex flex-col gap-1">
|
||||||
<div v-for="(it, j) in b.items ?? []" :key="j" class="flex items-start gap-2">
|
<div v-for="(it, j) in b.items ?? []" :key="j" class="flex items-start gap-2">
|
||||||
@@ -42,22 +54,22 @@ const blocks = computed(() => parseMarkdown(props.text));
|
|||||||
class="min-w-0 flex-1"
|
class="min-w-0 flex-1"
|
||||||
:class="b.tasks?.[j]?.checked ? 'text-neutral-400 line-through' : ''"
|
:class="b.tasks?.[j]?.checked ? 'text-neutral-400 line-through' : ''"
|
||||||
>
|
>
|
||||||
<MarkdownInline :tokens="it" />
|
<MarkdownInline :tokens="it" :tag-colors="tagColors" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
|
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
|
||||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :tag-colors="tagColors" /></li>
|
||||||
</ul>
|
</ul>
|
||||||
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
|
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
|
||||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :tag-colors="tagColors" /></li>
|
||||||
</ol>
|
</ol>
|
||||||
<pre
|
<pre
|
||||||
v-else-if="b.type === 'pre'"
|
v-else-if="b.type === 'pre'"
|
||||||
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
|
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
|
||||||
>{{ b.value ?? "" }}</pre
|
>{{ b.value ?? "" }}</pre
|
||||||
>
|
>
|
||||||
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" /></p>
|
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" /></p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -197,6 +197,23 @@ function labelChip(label: { name: string; color: string }): string {
|
|||||||
return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default;
|
return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only the tags the BODY is not already showing. `via_tag` means exactly "backed by
|
||||||
|
// text still in the note" since M311, so a chip for one printed the same tag twice —
|
||||||
|
// once where it was typed, once in this row — and the loud copy was the duplicate. A
|
||||||
|
// tag left in prose is tinted where it sits instead (MarkdownInline). What survives
|
||||||
|
// here is what the body cannot say: a tag lifted off its own line, and a label added
|
||||||
|
// through the picker.
|
||||||
|
const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag));
|
||||||
|
|
||||||
|
// The colour the operator stored for each of this note's tags, keyed by lowercased
|
||||||
|
// name — what MarkdownInline needs to tint a `#tag` the same as its chip would be.
|
||||||
|
// Lowercased because tags dedupe case-insensitively, so `#Todo` and `#todo` are one.
|
||||||
|
const tagColors = computed<Record<string, string>>(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const lb of props.note.labels) map[lb.name.toLowerCase()] = lb.color;
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
// Per-card color popover (recolor without opening the editor).
|
// Per-card color popover (recolor without opening the editor).
|
||||||
const colorOpen = ref(false);
|
const colorOpen = ref(false);
|
||||||
|
|
||||||
@@ -267,13 +284,19 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|||||||
Above the image and the body rather than beside them, because the body's first
|
Above the image and the body rather than beside them, because the body's first
|
||||||
line is the note's NAME (M13 steps 3 and 4) and a chip floated next to it would
|
line is the note's NAME (M13 steps 3 and 4) and a chip floated next to it would
|
||||||
compete with the thing that identifies the note. -->
|
compete with the thing that identifies the note. -->
|
||||||
<div v-if="note.labels.length" class="mb-2 flex flex-wrap gap-1">
|
<div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1">
|
||||||
|
<!-- Every chip carries the `#`, not just the ones derived from body text. That
|
||||||
|
branch used to distinguish a `#tag` from a picker label; it cannot any more,
|
||||||
|
because a tag whose text is still in the body no longer reaches this row at
|
||||||
|
all. What is left is all the same thing to the eye and to the vocabulary —
|
||||||
|
and the hash is what keeps a lifted chip reading as the `#todo` somebody
|
||||||
|
typed. Android's row says the same, which it did not before. -->
|
||||||
<span
|
<span
|
||||||
v-for="lb in note.labels"
|
v-for="lb in chipLabels"
|
||||||
:key="lb.id"
|
:key="lb.id"
|
||||||
class="rounded-full px-2 py-0.5 text-xs"
|
class="rounded-full px-2 py-0.5 text-xs"
|
||||||
:class="labelChip(lb)"
|
:class="labelChip(lb)"
|
||||||
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
|
>#{{ lb.name }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -314,7 +337,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|||||||
blank and the link is never unreachable. -->
|
blank and the link is never unreachable. -->
|
||||||
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
|
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
|
||||||
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
<MarkdownText :text="bodyPreview" toggleable @toggle="toggleTask" />
|
<MarkdownText :text="bodyPreview" :tag-colors="tagColors" toggleable @toggle="toggleTask" />
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="!note.body && !note.items.length && !note.attachments.length"
|
v-if="!note.body && !note.items.length && !note.attachments.length"
|
||||||
|
|||||||
@@ -613,7 +613,10 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
||||||
:class="labelChip(lb)"
|
:class="labelChip(lb)"
|
||||||
>
|
>
|
||||||
{{ lb.via_tag ? "#" + lb.name : lb.name }}
|
<!-- `#` on every chip, matching the card. This row still lists the tags
|
||||||
|
the BODY owns too — it is the control surface, and `via_tag` is what
|
||||||
|
decides whether there is a cross to remove one with. -->
|
||||||
|
#{{ lb.name }}
|
||||||
<button
|
<button
|
||||||
v-if="!lb.via_tag"
|
v-if="!lb.via_tag"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -57,6 +57,43 @@ export const LABEL_CHIP_CLASSES: Record<NoteColor, string> = {
|
|||||||
gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200 ring-1 ring-inset ring-neutral-700/60 dark:ring-neutral-200/60",
|
gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200 ring-1 ring-inset ring-neutral-700/60 dark:ring-neutral-200/60",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The ink for a `#tag` drawn where it was typed, rather than repeated as a chip.
|
||||||
|
//
|
||||||
|
// ONE Tailwind step deeper than LABEL_CHIP_CLASSES' text, and the difference is not a
|
||||||
|
// stylistic one. A chip carries its own `-100` fill, so its text has exactly one
|
||||||
|
// background to read against. Inline text sits on whatever the CARD is — which
|
||||||
|
// includes a gray-tagged card at `neutral-200`, where the chip's `-700` measured 3.98
|
||||||
|
// (green), 4.11 (orange) and 4.34 (teal), all under the 4.5 body text needs. At `-800`
|
||||||
|
// every hue lands between 5.63 and 12.01 in light, and `-300` gives 7.20 to 10.84 in
|
||||||
|
// dark, measured against every fill in NOTE_CARD_CLASSES_STRONG and every generated
|
||||||
|
// fill. One step for all ten beats three per-hue exceptions.
|
||||||
|
//
|
||||||
|
// Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`.
|
||||||
|
export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
|
||||||
|
default: "text-neutral-700 dark:text-neutral-300",
|
||||||
|
red: "text-red-800 dark:text-red-300",
|
||||||
|
orange: "text-orange-800 dark:text-orange-300",
|
||||||
|
yellow: "text-amber-800 dark:text-amber-300",
|
||||||
|
green: "text-green-800 dark:text-green-300",
|
||||||
|
teal: "text-teal-800 dark:text-teal-300",
|
||||||
|
blue: "text-blue-800 dark:text-blue-300",
|
||||||
|
purple: "text-purple-800 dark:text-purple-300",
|
||||||
|
pink: "text-pink-800 dark:text-pink-300",
|
||||||
|
gray: "text-neutral-800 dark:text-neutral-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The classes for one `#tag` in a note's own words.
|
||||||
|
*
|
||||||
|
* `picked` maps a lowercased tag name to the colour stored on that label, so a tag the
|
||||||
|
* operator has recoloured reads the same inline as it does on a chip. A tag the note
|
||||||
|
* does not carry as a label yet — just typed, not yet derived — is not in the map, and
|
||||||
|
* `resolveLabelColor` derives one from the name exactly as the chip would have.
|
||||||
|
*/
|
||||||
|
export function tagTextClasses(name: string, picked?: Record<string, string>): string {
|
||||||
|
return TAG_TEXT_CLASSES[resolveLabelColor({ name, color: picked?.[name.toLowerCase()] })];
|
||||||
|
}
|
||||||
|
|
||||||
// Solid fills for graph nodes (SVG needs concrete colors, not Tailwind bg classes).
|
// Solid fills for graph nodes (SVG needs concrete colors, not Tailwind bg classes).
|
||||||
// Mid-tone hues read on both the light and dark graph background.
|
// Mid-tone hues read on both the light and dark graph background.
|
||||||
export const NOTE_NODE_FILL: Record<NoteColor, string> = {
|
export const NOTE_NODE_FILL: Record<NoteColor, string> = {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
// a heading.
|
// a heading.
|
||||||
|
|
||||||
export interface InlineToken {
|
export interface InlineToken {
|
||||||
type: "text" | "bold" | "italic" | "code";
|
/** `tag` carries the NAME, without the leading `#` — it is both what gets looked up
|
||||||
|
* for a colour and what is rendered, so the renderer puts the `#` back. */
|
||||||
|
type: "text" | "bold" | "italic" | "code" | "tag";
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +39,22 @@ export interface TaskMeta {
|
|||||||
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is
|
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is
|
||||||
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now
|
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now
|
||||||
// renders as the literal characters someone typed, which is what it always was.
|
// renders as the literal characters someone typed, which is what it always was.
|
||||||
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
|
// `#tag` is LAST in the alternation and that is load-bearing twice over. JS tries
|
||||||
|
// alternatives left to right, so a `#tag` inside backticks is claimed by `code` first
|
||||||
|
// and stays literal — matching the core, where a fenced block's contents are code.
|
||||||
|
// And a tag is the one token here that is not delimiter-based, so it must not get a
|
||||||
|
// chance to start inside `**bold #x**`.
|
||||||
|
//
|
||||||
|
// The grammar MIRRORS `line_tags` in core/src/local/derive.rs, which is the definition:
|
||||||
|
// a `#` at a word boundary (the preceding character is neither a tag character nor
|
||||||
|
// another `#`, so `a#b` and `##x` are not tags), a letter immediately after it, then
|
||||||
|
// alphanumerics, `_` and `-`. Rust's `is_alphanumeric` is `Alphabetic | N`, hence the
|
||||||
|
// property escapes rather than `\w` — and hence the `u` flag.
|
||||||
|
//
|
||||||
|
// A heading cannot collide with this: `parseMarkdown` requires a space after the `#`s,
|
||||||
|
// which `#tag` by definition does not have.
|
||||||
|
const INLINE_RE =
|
||||||
|
/(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)|((?<![\p{Alphabetic}\p{N}_#-])#\p{Alphabetic}[\p{Alphabetic}\p{N}_-]*)/gu;
|
||||||
|
|
||||||
export function parseInline(text: string): InlineToken[] {
|
export function parseInline(text: string): InlineToken[] {
|
||||||
const tokens: InlineToken[] = [];
|
const tokens: InlineToken[] = [];
|
||||||
@@ -49,6 +66,7 @@ export function parseInline(text: string): InlineToken[] {
|
|||||||
const raw = m[0];
|
const raw = m[0];
|
||||||
if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
|
if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
|
||||||
else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
|
else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
|
||||||
|
else if (m[5]) tokens.push({ type: "tag", value: raw.slice(1) });
|
||||||
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
|
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
|
||||||
last = m.index + raw.length;
|
last = m.index + raw.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,13 @@ export interface NoteLabel {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
// True when this label is attached because of a #tag in the note body (kept in
|
// True when the label is backed by text STILL IN THE BODY — a `#tag` written
|
||||||
// sync with the text); false = added manually via the picker.
|
// mid-sentence, kept in sync with those words. False covers both a label added
|
||||||
|
// through the picker and a tag lifted off a line of its own (M311), which is why
|
||||||
|
// it is also what decides whether a chip can be removed with a cross.
|
||||||
|
//
|
||||||
|
// The card reads it the other way round: a true here means the body is already
|
||||||
|
// showing this tag, so the chip would be the second copy and is not drawn.
|
||||||
via_tag: boolean;
|
via_tag: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user