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:
@@ -1,10 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
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
|
||||
// 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>
|
||||
|
||||
<!-- 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'"
|
||||
class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10"
|
||||
>{{ 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
|
||||
>
|
||||
|
||||
@@ -3,7 +3,13 @@ import { computed } from "vue";
|
||||
import { parseMarkdown } from "../notes/markdown";
|
||||
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
|
||||
// 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
|
||||
@@ -15,14 +21,20 @@ const blocks = computed(() => parseMarkdown(props.text));
|
||||
<template>
|
||||
<div class="space-y-1.5 break-words">
|
||||
<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>
|
||||
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4>
|
||||
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
|
||||
<h3 v-if="b.type === 'h1'" class="text-base font-bold">
|
||||
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
|
||||
</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
|
||||
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"
|
||||
>
|
||||
<MarkdownInline :tokens="b.inline ?? []" />
|
||||
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
|
||||
</blockquote>
|
||||
<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">
|
||||
@@ -42,22 +54,22 @@ const blocks = computed(() => parseMarkdown(props.text));
|
||||
class="min-w-0 flex-1"
|
||||
:class="b.tasks?.[j]?.checked ? 'text-neutral-400 line-through' : ''"
|
||||
>
|
||||
<MarkdownInline :tokens="it" />
|
||||
<MarkdownInline :tokens="it" :tag-colors="tagColors" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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"
|
||||
>{{ 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -197,6 +197,23 @@ function labelChip(label: { name: string; color: string }): string {
|
||||
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).
|
||||
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
|
||||
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. -->
|
||||
<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
|
||||
v-for="lb in note.labels"
|
||||
v-for="lb in chipLabels"
|
||||
:key="lb.id"
|
||||
class="rounded-full px-2 py-0.5 text-xs"
|
||||
:class="labelChip(lb)"
|
||||
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
|
||||
>#{{ lb.name }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -314,7 +337,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
blank and the link is never unreachable. -->
|
||||
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
|
||||
<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>
|
||||
<p
|
||||
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="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
|
||||
v-if="!lb.via_tag"
|
||||
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",
|
||||
};
|
||||
|
||||
// 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).
|
||||
// Mid-tone hues read on both the light and dark graph background.
|
||||
export const NOTE_NODE_FILL: Record<NoteColor, string> = {
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
// a heading.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -37,7 +39,22 @@ export interface TaskMeta {
|
||||
// `[[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
|
||||
// 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[] {
|
||||
const tokens: InlineToken[] = [];
|
||||
@@ -49,6 +66,7 @@ export function parseInline(text: string): InlineToken[] {
|
||||
const raw = m[0];
|
||||
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[5]) tokens.push({ type: "tag", value: raw.slice(1) });
|
||||
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
|
||||
last = m.index + raw.length;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,13 @@ export interface NoteLabel {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
// True when this label is attached because of a #tag in the note body (kept in
|
||||
// sync with the text); false = added manually via the picker.
|
||||
// True when the label is backed by text STILL IN THE BODY — a `#tag` written
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user