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:
co-authored by
Claude Opus 4.7
parent
3e20ba543c
commit
12986454eb
@@ -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> = {
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
};
|
||||
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) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] ?? c
|
||||
));
|
||||
} else {
|
||||
// Fall back to escaped raw form.
|
||||
display = t.raw.replace(/[&<>"']/g, (c) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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">​</span>';
|
||||
return html;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user