fix(spa): rich-text caret + wildcards-list syntax pills

Two small but visibly broken bits in the rich-text rendering layer:

1. RichTextInput caret floats away from the visible text whenever an
   `@{uuid}` ref is present. The mirror was substituting the 12-char
   `@{abcd1234}` UUID form for a 6-char `@name` while the textarea
   underneath still tracked the full 12 chars — so the native caret
   sat ~6 chars to the right of the chip. The mirror is a transparent
   overlay; its glyph width has to match the textarea exactly for
   caret tracking to work.

   Now we only swap UUID → name in the rest mode (`!focused`); while
   focused, the mirror keeps the raw `@{uuid}` form so caret stays glued
   to the text. The dual focused/rest mode is the same hook the chip
   padding already uses.

2. Wildcards list never lit up the Syntax-column ref / incoming pills.
   `buildWildcardGraph` keys outgoing/incoming by `mod.id` (UUID — the
   same identifier the tokenizer's `@{8hex}` ref token captures), but
   `Wildcards.vue` looked them up by `wildcardVarName(row)` (a `$var`
   slug). Lookup always missed → counts always 0 → no pills. Same bug
   in the "is-referenced" filter check. Fixed both to look up by `id`.

   Also pass `uuidToName` through to `RichTextPreview` in the row
   expansion so refs there read as `@name` instead of raw `@{abcd1234}`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
DumiFlex
2026-04-29 00:55:27 +03:00
co-authored by Claude Opus 4.7
parent 3e20ba543c
commit 12986454eb
2 changed files with 48 additions and 29 deletions
+35 -23
View File
@@ -73,42 +73,54 @@ const popupPos = ref<{ top: number; left: number; width: number; flipped: boolea
// --- Tokenize + mirror HTML (pre-escaped — see richTokenize.ts). ---
const tokens = computed(() => tokenizeRich(props.modelValue || ""));
// SAFE: `mirrorHtmlWithIdx` HTML-escapes every `raw` payload before
// concatenation. The resulting string only contains tags we generated, so
// `v-html` cannot inject user-controlled markup.
// When `surface !== "wildcard"`, ref tokens get an extra "ignored" class
// so they render with muted styling.
// SAFE: every `raw` payload is HTML-escaped before concatenation. The only
// tags in the output are ones we emit ourselves, so `v-html` cannot inject
// user-controlled markup.
//
// CARET ALIGNMENT — read this before changing the rendering logic.
// The mirror is painted behind a transparent textarea. The native caret
// only stays glued to the visible glyphs if the mirror's text width
// matches the textarea's text width *exactly*. That means we MUST emit
// the same characters the textarea sees — `@{abcd1234}` (12 chars), not
// `@shirt` (6 chars). Substituting the UUID for a friendlier name while
// the user is editing leaves the caret floating to the right of the chip
// by the width-difference.
//
// So: while focused, render the raw `@{uuid}` form. When blurred
// (`wp-rt--rest`), swap in the human name for readability — there's no
// caret to misalign at rest, and the rest-mode pill padding already
// shifts widths anyway.
//
// `surface !== "wildcard"` stamps refs with an "ignored" class so they
// render muted on surfaces where `@`-refs aren't resolved.
const ESC: Record<string, string> = {
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
};
function escHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => ESC[c] ?? c);
}
const mirrorHtml = computed(() => {
const toks = tokens.value;
const isWildcard = props.surface === "wildcard";
const map = props.uuidToName;
const editing = focused.value;
let html = "";
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
let cls = `wp-rt-${t.kind}`;
let display = t.raw;
let display = escHtml(t.raw);
if (t.kind === "ref") {
if (!isWildcard) cls += " wp-rt-ref--ignored";
// Replace `@{uuid}` with `@name` display form when name is known.
const uuid = t.meta?.uuid;
if (uuid && map.has(uuid)) {
const name = map.get(uuid)!;
// Escape the name portion (uuid chars are hex-safe but name may not be).
display = "@" + name.replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] ?? c
));
} else {
// Fall back to escaped raw form.
display = t.raw.replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] ?? c
));
if (!editing && uuid && map.has(uuid)) {
// Rest-mode: show `@name` for readability. Caret alignment doesn't
// matter when the textarea isn't focused.
display = "@" + escHtml(map.get(uuid)!);
}
html += `<span class="${cls}" data-idx="${i}">${display}</span>`;
} else {
html += `<span class="${cls}" data-idx="${i}">${t.raw.replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] ?? c
))}</span>`;
// Editing-mode falls through with the raw `@{uuid}` form so caret
// pixel-tracks the underlying textarea characters.
}
html += `<span class="${cls}" data-idx="${i}">${display}</span>`;
}
html += '<span class="wp-rt-tail">&#x200B;</span>';
return html;
+13 -6
View File
@@ -13,7 +13,6 @@ import type { ModuleRow, CategoryRow } from "../api/types";
import {
buildWildcardGraph,
getWildcardSyntax,
wildcardVarName,
} from "../utils/wildcardSyntax";
const router = useRouter();
@@ -49,10 +48,13 @@ interface SyntaxView {
}
function syntaxView(row: ModuleRow): SyntaxView {
// `buildWildcardGraph` keys the maps by `mod.id` (the 8-hex UUID — the same
// identifier captured by the tokenizer's `@{8hex}` ref token). Looking up
// by `wildcardVarName(row)` (a `$var` slug) always missed, which was why
// the Syntax column never lit up its ref/incoming pills. Use `row.id`.
const sx = getWildcardSyntax(row);
const name = wildcardVarName(row);
const out = wildcardGraph.value.outgoing.get(name);
const inc = wildcardGraph.value.incoming.get(name);
const out = wildcardGraph.value.outgoing.get(row.id);
const inc = wildcardGraph.value.incoming.get(row.id);
return {
hasInline: sx.hasInline,
outgoing: out ? [...out] : [],
@@ -75,7 +77,8 @@ const extraFilters = computed(() => [
key: "is-referenced",
label: "Referenced by others",
check: (m: ModuleRow) => {
const inc = wildcardGraph.value.incoming.get(wildcardVarName(m));
// Graph is keyed by `mod.id` (UUID) — see `syntaxView` for context.
const inc = wildcardGraph.value.incoming.get(m.id);
return !!inc && inc.size > 0;
},
},
@@ -323,7 +326,11 @@ function isValid(row: ModuleRow): boolean {
<template v-for="(opt, i) in topOptions(row)" :key="i">
<span class="wp-mono wp-dim">{{ opt.weight ?? 1 }}×</span>
<span class="wp-mono wp-opts-grid__val">
<RichTextPreview v-if="opt.value" :value="opt.value" />
<RichTextPreview
v-if="opt.value"
:value="opt.value"
:uuid-to-name="wildcardGraph.uuidToName"
/>
<span v-else class="wp-dim">(empty)</span>
</span>
<div class="wp-opts-grid__bar">