feat(spa): rich text input + preview with token chips and autocomplete

Add a reusable Vue component pair for editing wildcard expression syntax
with syntax-highlighted overlays. The mirror layer renders styled chips
behind a transparent textarea/input so the native caret stays in pixel-
perfect alignment while typing; chips switch to padded pill chrome when
blurred.

- src/manager/utils/richTokenize.ts: pure tokenizer + HTML emitter
  (translation of docs/.../rich-input.jsx tokenizeRich + mirrorHTMLWithIdx)
- src/manager/components/RichTextInput.vue: input/textarea with mirror,
  $/@ autocomplete (arrow up/down, Enter/Tab, Esc, viewport-aware flip)
- src/manager/components/RichTextPreview.vue: read-only counterpart for
  list-row expansions
- 16 tokenizer unit tests + 15 component tests

Not yet wired into form views; that lands in a follow-up.
This commit is contained in:
DumiFlex
2026-04-27 04:24:40 +03:00
parent f18be1eaff
commit 6a5bbb40ef
5 changed files with 1196 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import RichTextInput from "../components/RichTextInput.vue";
import RichTextPreview from "../components/RichTextPreview.vue";
// `flushPromises()` from @vue/test-utils gives us reactivity-flush behaviour
// without needing to import `nextTick` from `vue` (the test alias maps `vue`
// to its runtime-only bundler bundle which doesn't re-export it).
const tick = flushPromises;
describe("RichTextInput.vue", () => {
it("renders bound value inside the input layer", () => {
const wrap = mount(RichTextInput, {
props: { modelValue: "$person walks" },
});
const input = wrap.find("input");
expect(input.exists()).toBe(true);
expect((input.element as HTMLInputElement).value).toBe("$person walks");
});
it("emits update:modelValue on input", async () => {
const wrap = mount(RichTextInput, {
props: { modelValue: "" },
});
const input = wrap.find("input");
await input.setValue("hello");
const events = wrap.emitted("update:modelValue") ?? [];
expect(events.length).toBeGreaterThan(0);
expect(events[events.length - 1]).toEqual(["hello"]);
});
it("renders multiline as <textarea> when multiline=true", () => {
const wrap = mount(RichTextInput, {
props: { modelValue: "line\nline", multiline: true, rows: 6 },
});
expect(wrap.find("textarea").exists()).toBe(true);
expect(wrap.find("input").exists()).toBe(false);
expect(wrap.find("textarea").attributes("rows")).toBe("6");
});
it("paints chip markup in the mirror layer for $var/@ref/{a|b|c}", () => {
const wrap = mount(RichTextInput, {
props: { modelValue: "$person sees @colors of {red|blue}" },
});
const mirror = wrap.find(".wp-rt__mirror");
expect(mirror.exists()).toBe(true);
const html = mirror.html();
expect(html).toContain("wp-rt-var");
expect(html).toContain("wp-rt-ref");
expect(html).toContain("wp-rt-dp-brace");
expect(html).toContain("wp-rt-dp-pipe");
});
it("renders $$ escapes as escape spans (not vars)", () => {
const wrap = mount(RichTextInput, {
props: { modelValue: "$$literal" },
});
const html = wrap.find(".wp-rt__mirror").html();
expect(html).toContain("wp-rt-escape");
// The opening "$$" should not be classified as var.
expect(html).not.toMatch(/wp-rt-var[^]*\$\$/);
});
it("renders # comment lines greyed out", () => {
const wrap = mount(RichTextInput, {
props: { modelValue: "# noted", multiline: true },
});
expect(wrap.find(".wp-rt__mirror").html()).toContain("wp-rt-comment");
});
it("toggles wp-rt--focused / wp-rt--rest classes on focus / blur", async () => {
const wrap = mount(RichTextInput, {
props: { modelValue: "" },
attachTo: document.body,
});
const input = wrap.find("input");
expect(wrap.classes()).toContain("wp-rt--rest");
await input.trigger("focus");
expect(wrap.classes()).toContain("wp-rt--focused");
expect(wrap.classes()).not.toContain("wp-rt--rest");
await input.trigger("blur");
await flushPromises();
expect(wrap.classes()).toContain("wp-rt--rest");
wrap.unmount();
});
it("opens autocomplete and filters by prefix when typing $", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
varSuggestions: ["person", "people", "thing"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "$pe";
el.setSelectionRange(3, 3);
await input.trigger("input");
await tick();
const suggestions = wrap.findAll(".wp-rt-suggestions__item");
const labels = suggestions.map((b) => b.text());
expect(labels.length).toBeGreaterThan(0);
expect(labels.some((l) => l.includes("person"))).toBe(true);
expect(labels.some((l) => l.includes("people"))).toBe(true);
expect(labels.some((l) => l.includes("thing"))).toBe(false);
wrap.unmount();
});
it("opens autocomplete with refSuggestions when typing @", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
varSuggestions: ["alpha"],
refSuggestions: ["beta", "betatron"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "@be";
el.setSelectionRange(3, 3);
await input.trigger("input");
await tick();
const labels = wrap.findAll(".wp-rt-suggestions__item").map((b) => b.text());
expect(labels.some((l) => l.includes("beta"))).toBe(true);
expect(labels.some((l) => l.includes("alpha"))).toBe(false);
wrap.unmount();
});
it("inserts a suggestion on Enter", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
varSuggestions: ["person"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "$pe";
el.setSelectionRange(3, 3);
await input.trigger("input");
await tick();
await input.trigger("keydown", { key: "Enter" });
const events = wrap.emitted("update:modelValue") ?? [];
expect(events[events.length - 1]).toEqual(["$person"]);
wrap.unmount();
});
it("closes autocomplete on Escape", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
varSuggestions: ["person"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "$p";
el.setSelectionRange(2, 2);
await input.trigger("input");
await tick();
expect(wrap.find(".wp-rt-suggestions").exists()).toBe(true);
await input.trigger("keydown", { key: "Escape" });
await tick();
expect(wrap.find(".wp-rt-suggestions").exists()).toBe(false);
wrap.unmount();
});
it("keeps mirror text identical to value while focused (no chip padding drift)", async () => {
const wrap = mount(RichTextInput, {
props: { modelValue: "$person and @ref" },
attachTo: document.body,
});
const input = wrap.find("input");
await input.trigger("focus");
const mirrorText = wrap.find(".wp-rt__mirror").text();
// The trailing wp-rt-tail span injects a zero-width space; strip it before
// comparing so we can assert the visible glyphs match the value exactly.
expect(mirrorText.replace(/​/g, "")).toBe("$person and @ref");
wrap.unmount();
});
it("forwards aria-label and disabled to the underlying input", () => {
const wrap = mount(RichTextInput, {
props: { modelValue: "x", ariaLabel: "Rule value", disabled: true },
});
const input = wrap.find("input");
expect(input.attributes("aria-label")).toBe("Rule value");
expect(input.attributes("disabled")).toBeDefined();
});
});
describe("RichTextPreview.vue", () => {
it("renders chip markup for $var without a textarea", () => {
const wrap = mount(RichTextPreview, { props: { value: "$person" } });
expect(wrap.find("textarea").exists()).toBe(false);
expect(wrap.find("input").exists()).toBe(false);
expect(wrap.html()).toContain("wp-rt-var");
});
it("renders empty value without throwing", () => {
const wrap = mount(RichTextPreview, { props: { value: "" } });
expect(wrap.exists()).toBe(true);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { tokenizeRich, mirrorHtmlWithIdx } from "../utils/richTokenize";
describe("tokenizeRich", () => {
it("returns empty array for empty input", () => {
expect(tokenizeRich("")).toEqual([]);
});
it("emits a single text token for plain prose", () => {
const tokens = tokenizeRich("hello world");
expect(tokens).toHaveLength(1);
expect(tokens[0]).toMatchObject({ kind: "text", raw: "hello world", start: 0, end: 11 });
});
it("recognises $varname tokens", () => {
const tokens = tokenizeRich("a $person b");
const v = tokens.find((t) => t.kind === "var");
expect(v).toBeDefined();
expect(v?.raw).toBe("$person");
expect(v?.meta?.name).toBe("person");
expect(v?.start).toBe(2);
expect(v?.end).toBe(9);
});
it("recognises @ref tokens", () => {
const tokens = tokenizeRich("see @colors");
const r = tokens.find((t) => t.kind === "ref");
expect(r).toBeDefined();
expect(r?.raw).toBe("@colors");
expect(r?.meta?.name).toBe("colors");
});
it("treats $$ as an escape, not a var", () => {
const tokens = tokenizeRich("price $$5");
const escapes = tokens.filter((t) => t.kind === "escape");
const vars = tokens.filter((t) => t.kind === "var");
expect(escapes).toHaveLength(1);
expect(escapes[0].raw).toBe("$$");
expect(vars).toHaveLength(0);
});
it("treats @@ as an escape", () => {
const tokens = tokenizeRich("hi @@user");
const escapes = tokens.filter((t) => t.kind === "escape");
expect(escapes).toHaveLength(1);
expect(escapes[0].raw).toBe("@@");
});
it("tokenises {a|b|c} inline-choice with brace + pipe markers", () => {
const tokens = tokenizeRich("{a|b|c}");
const kinds = tokens.map((t) => t.kind);
expect(kinds[0]).toBe("dp-brace");
expect(kinds[kinds.length - 1]).toBe("dp-brace");
expect(kinds.filter((k) => k === "dp-pipe")).toHaveLength(2);
// Open brace at 0, close brace at end-1.
expect(tokens[0].start).toBe(0);
expect(tokens[tokens.length - 1].end).toBe(7);
});
it("recognises a {N::weighted} option marker", () => {
const tokens = tokenizeRich("{2::red|blue}");
const w = tokens.find((t) => t.kind === "dp-weight");
expect(w).toBeDefined();
expect(w?.raw).toBe("2::");
expect(w?.meta?.weight).toBe(2);
});
it("recognises a {N$$sep$$...} multi-select header", () => {
const tokens = tokenizeRich("{2$$, $$a|b}");
const m = tokens.find((t) => t.kind === "dp-multi");
expect(m).toBeDefined();
expect(m?.raw.startsWith("2$$")).toBe(true);
expect(m?.meta?.range).toBe("2");
expect(m?.meta?.sep).toBe(", ");
});
it("recognises a quantifier prefix N#$var", () => {
const tokens = tokenizeRich("5#$person");
const q = tokens.find((t) => t.kind === "quantifier");
const v = tokens.find((t) => t.kind === "var");
expect(q).toBeDefined();
expect(q?.raw).toBe("5#");
expect(q?.meta?.count).toBe(5);
expect(v?.raw).toBe("$person");
});
it("recognises # comment lines", () => {
const tokens = tokenizeRich("# this is a comment\nrest");
expect(tokens[0].kind).toBe("comment");
expect(tokens[0].raw).toBe("# this is a comment");
});
it("does not flag mid-token # as a comment", () => {
const tokens = tokenizeRich("foo#bar");
expect(tokens.some((t) => t.kind === "comment")).toBe(false);
});
it("emits contiguous tokens covering the full input", () => {
const text = "5#$person says @greet at $$5: {a|b}";
const tokens = tokenizeRich(text);
expect(tokens[0].start).toBe(0);
expect(tokens[tokens.length - 1].end).toBe(text.length);
for (let i = 1; i < tokens.length; i++) {
expect(tokens[i].start).toBe(tokens[i - 1].end);
}
});
});
describe("mirrorHtmlWithIdx", () => {
it("emits a span per token with class wp-rt-{kind} and data-idx", () => {
const html = mirrorHtmlWithIdx(tokenizeRich("$x"));
expect(html).toContain('class="wp-rt-var"');
expect(html).toContain('data-idx="0"');
expect(html).toContain("$x");
});
it("escapes HTML special characters in raw text", () => {
const html = mirrorHtmlWithIdx(tokenizeRich("<script>&\"'"));
expect(html).toContain("&lt;");
expect(html).toContain("&gt;");
expect(html).toContain("&amp;");
expect(html).toContain("&quot;");
expect(html).toContain("&#39;");
expect(html).not.toContain("<script>");
});
it("appends a trailing wp-rt-tail span", () => {
const html = mirrorHtmlWithIdx(tokenizeRich("hello"));
expect(html).toContain('class="wp-rt-tail"');
});
});
+509
View File
@@ -0,0 +1,509 @@
<script setup lang="ts">
/**
* RichTextInput
*
* A textarea/input replacement that renders a syntax-highlight overlay for
* Wildcard Pipeline expression syntax (`$var`, `@ref`, `{a|b|c}`, `N#$var`,
* `# comment`, `$$`/`@@` escapes). The user-visible glyphs come from a
* mirrored `<div>` painted behind a transparent textarea — see the
* focused/rest dual mode comments below for why this gymnastic is needed.
*
* Reference: docs/design-handoff/wildcardpipeline/project/rich-input.jsx.
*/
import { computed, nextTick, ref, watch } from "vue";
import { tokenizeRich, mirrorHtmlWithIdx } from "../utils/richTokenize";
interface Props {
modelValue: string;
multiline?: boolean;
rows?: number;
placeholder?: string;
varSuggestions?: string[];
refSuggestions?: string[];
ariaLabel?: string;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
multiline: false,
rows: 4,
placeholder: "",
varSuggestions: () => [],
refSuggestions: () => [],
ariaLabel: undefined,
disabled: false,
});
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
// --- Refs ---
const inputEl = ref<HTMLTextAreaElement | HTMLInputElement | null>(null);
const mirrorEl = ref<HTMLDivElement | null>(null);
const popoverEl = ref<HTMLDivElement | null>(null);
const focused = ref(false);
// Autocomplete state.
const acOpen = ref(false);
const acQuery = ref("");
const acTrigger = ref<"$" | "@">("$");
const acStart = ref(-1);
const acActive = ref(0);
const acFlipUp = ref(false);
// --- 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.
const mirrorHtml = computed(() => mirrorHtmlWithIdx(tokens.value));
// --- Suggestion list filtering. ---
const acItems = computed(() => {
if (!acOpen.value) return [];
const pool = acTrigger.value === "@" ? props.refSuggestions : props.varSuggestions;
const q = acQuery.value.toLowerCase();
return pool
.filter((label) => label.toLowerCase().includes(q))
.slice(0, 8);
});
watch(acItems, (items) => {
if (acActive.value >= items.length) acActive.value = 0;
});
// --- Mirror/textarea scroll sync (multi-line scrolling alignment). ---
function syncScroll(): void {
const ta = inputEl.value;
const mirror = mirrorEl.value;
if (!ta || !mirror) return;
mirror.scrollTop = ta.scrollTop;
mirror.scrollLeft = ta.scrollLeft;
}
watch(() => props.modelValue, () => { void nextTick(syncScroll); });
// --- Autocomplete probe: scan back from caret through `[a-zA-Z0-9_]` until
// we hit a `$` or `@` trigger (and bail if it's a `$$` / `@@` escape). ---
function probeAutocomplete(str: string, caret: number): {
start: number;
query: string;
trigger: "$" | "@";
} | null {
let i = caret - 1;
while (i >= 0 && /[a-zA-Z0-9_]/.test(str[i])) i--;
if (i < 0) return null;
const trigger = str[i];
if (trigger !== "$" && trigger !== "@") return null;
if (i > 0 && str[i - 1] === trigger) return null; // mid-escape `$$x`
if (str[i + 1] === trigger) return null; // immediate `$$`
return { start: i, query: str.slice(i + 1, caret), trigger };
}
function maybeFlipUp(): void {
// Open downwards by default; flip if there isn't enough room below.
const wrap = inputEl.value?.parentElement;
if (!wrap) { acFlipUp.value = false; return; }
const rect = wrap.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
acFlipUp.value = spaceBelow < 200 && rect.top > 200;
}
// --- Handlers ---
function onInput(e: Event): void {
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
const next = target.value;
emit("update:modelValue", next);
const caret = target.selectionStart ?? next.length;
const hit = probeAutocomplete(next, caret);
if (hit) {
acOpen.value = true;
acStart.value = hit.start;
acQuery.value = hit.query;
acTrigger.value = hit.trigger;
acActive.value = 0;
maybeFlipUp();
} else {
acOpen.value = false;
}
}
function refreshAutocompleteFromCaret(): void {
const el = inputEl.value;
if (!el) return;
const caret = el.selectionStart ?? 0;
const hit = probeAutocomplete(props.modelValue || "", caret);
if (hit) {
acOpen.value = true;
acStart.value = hit.start;
acQuery.value = hit.query;
acTrigger.value = hit.trigger;
} else {
acOpen.value = false;
}
}
function onKeyDown(e: KeyboardEvent): void {
if (acOpen.value && acItems.value.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
acActive.value = (acActive.value + 1) % acItems.value.length;
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
acActive.value = (acActive.value - 1 + acItems.value.length) % acItems.value.length;
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
applyAutocomplete(acItems.value[acActive.value]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
acOpen.value = false;
return;
}
}
}
function onSelect(): void {
// `select` fires on caret moves too in modern browsers; refresh AC state
// so e.g. arrow-key navigation into a `$tok` opens the menu correctly.
if (focused.value) refreshAutocompleteFromCaret();
}
function applyAutocomplete(label: string | undefined): void {
if (!label) return;
const el = inputEl.value;
if (!el) return;
const value = props.modelValue || "";
const caret = el.selectionStart ?? value.length;
const before = value.slice(0, acStart.value);
const after = value.slice(caret);
const inserted = acTrigger.value + label;
const next = before + inserted + after;
emit("update:modelValue", next);
acOpen.value = false;
void nextTick(() => {
const pos = (before + inserted).length;
el.focus();
el.setSelectionRange(pos, pos);
});
}
function onFocus(): void {
focused.value = true;
}
function onBlur(e: FocusEvent): void {
// Keep autocomplete open if focus moved into the popover (mousedown on
// an item is dispatched before the textarea blur event in some browsers).
const next = e.relatedTarget as Node | null;
if (next && popoverEl.value?.contains(next)) return;
focused.value = false;
// Slight delay so a mousedown on a suggestion still fires before close.
window.setTimeout(() => {
acOpen.value = false;
}, 120);
}
function onSuggestionMouseDown(e: MouseEvent, label: string): void {
// `mousedown` (not click) so we beat the textarea blur.
e.preventDefault();
applyAutocomplete(label);
}
</script>
<template>
<div
class="wp-rt"
:class="[
multiline ? 'wp-rt--multi' : 'wp-rt--single',
focused ? 'wp-rt--focused' : 'wp-rt--rest',
]"
:data-focused="focused ? '' : null"
>
<!-- The mirror is read-only chrome; pointer-events:none in CSS.
`v-html` is safe because mirrorHtmlWithIdx() escapes all token text. -->
<div
ref="mirrorEl"
class="wp-rt__mirror"
:class="multiline ? 'wp-rt__mirror--multi' : 'wp-rt__mirror--single'"
aria-hidden="true"
v-html="mirrorHtml"
/>
<textarea
v-if="multiline"
ref="inputEl"
class="wp-rt__input wp-rt__input--multi"
:value="modelValue"
:placeholder="placeholder"
:rows="rows"
:aria-label="ariaLabel"
:disabled="disabled"
spellcheck="false"
@input="onInput"
@keydown="onKeyDown"
@select="onSelect"
@keyup="onSelect"
@click="onSelect"
@scroll="syncScroll"
@focus="onFocus"
@blur="onBlur"
/>
<input
v-else
ref="inputEl"
class="wp-rt__input wp-rt__input--single"
type="text"
:value="modelValue"
:placeholder="placeholder"
:aria-label="ariaLabel"
:disabled="disabled"
spellcheck="false"
@input="onInput"
@keydown="onKeyDown"
@select="onSelect"
@keyup="onSelect"
@click="onSelect"
@scroll="syncScroll"
@focus="onFocus"
@blur="onBlur"
>
<div
v-if="acOpen && acItems.length > 0"
ref="popoverEl"
class="wp-rt-suggestions"
:class="{ 'wp-rt-suggestions--up': acFlipUp }"
role="listbox"
>
<div class="wp-rt-suggestions__head">
<span class="wp-rt-suggestions__query">{{ acTrigger }}{{ acQuery }}</span>
<span class="wp-rt-suggestions__hint">↑↓ Enter · Esc</span>
</div>
<button
v-for="(label, i) in acItems"
:key="label"
type="button"
class="wp-rt-suggestions__item"
:data-active="i === acActive ? '' : null"
role="option"
:aria-selected="i === acActive"
@mousedown="(e) => onSuggestionMouseDown(e, label)"
@mouseenter="acActive = i"
>
<span class="wp-rt-suggestions__trigger">{{ acTrigger }}</span>{{ label }}
</button>
</div>
</div>
</template>
<style scoped>
/* Container ------------------------------------------------------------- */
.wp-rt {
position: relative;
width: 100%;
background: var(--wp-bg-2, #15151f);
border: 1px solid var(--wp-border, rgba(255, 255, 255, 0.08));
border-radius: 7px;
transition: border-color .12s, background .12s, box-shadow .12s;
overflow: hidden;
box-sizing: border-box;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 12.5px;
}
.wp-rt--focused {
border-color: var(--wp-accent-500, #8b5cf6);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 25%, transparent);
background: var(--wp-bg-1, #11111b);
}
/* Mirror layer — paints styled tokens behind the textarea.
Two visual modes are toggled by the parent class:
wp-rt--focused : while editing, chips are zero-width visual decorations
(background + box-shadow only, no padding/margin/border)
so the mirror text width matches the textarea text width
exactly and the native caret tracks correctly.
wp-rt--rest : when blurred, chips get a hair of horizontal padding
so they read as proper pills at a glance. */
.wp-rt__mirror {
position: absolute;
inset: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
color: var(--wp-text, #e7e7ee);
pointer-events: none;
user-select: none;
letter-spacing: 0;
box-sizing: border-box;
overflow: hidden;
}
.wp-rt__mirror--single {
padding: 0 10px;
line-height: var(--wp-input-h, 34px);
white-space: pre;
}
.wp-rt__mirror--multi {
padding: 8px 10px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* Input layer — transparent text so the mirror shows through; visible caret. */
.wp-rt__input {
position: relative;
display: block;
width: 100%;
margin: 0;
border: none;
outline: none;
background: transparent;
color: transparent;
caret-color: var(--wp-text, #e7e7ee);
font-family: inherit;
font-size: inherit;
letter-spacing: 0;
box-sizing: border-box;
resize: none;
}
.wp-rt__input--single {
height: var(--wp-input-h, 34px);
padding: 0 10px;
line-height: var(--wp-input-h, 34px);
}
.wp-rt__input--multi {
padding: 8px 10px;
line-height: 1.5;
resize: vertical;
min-height: 72px;
white-space: pre-wrap;
word-break: break-word;
}
.wp-rt__input::selection {
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 38%, transparent);
color: transparent;
}
.wp-rt__input::placeholder {
color: var(--wp-text-dim, #6e6e7c);
}
/* Token spans ----------------------------------------------------------- */
.wp-rt :deep(.wp-rt-text) { color: var(--wp-text, #e7e7ee); }
.wp-rt :deep(.wp-rt-comment) { color: var(--wp-text-dim, #6e6e7c); font-style: italic; }
.wp-rt :deep(.wp-rt-escape) { color: var(--wp-text-muted, #a1a1ad); opacity: 0.7; }
.wp-rt :deep(.wp-rt-tail) { color: transparent; }
.wp-rt :deep(.wp-rt-var) {
color: var(--wp-accent-text-strong, var(--wp-accent-text, #c4b5fd));
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 15%, transparent);
border-radius: 4px;
font-weight: 500;
}
.wp-rt :deep(.wp-rt-ref) {
color: var(--wp-kind-wildcard, #f0abfc);
background: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 12%, transparent);
border-radius: 4px;
font-weight: 500;
}
.wp-rt :deep(.wp-rt-dp-brace) { color: var(--wp-warn, #fcd34d); font-weight: 600; }
.wp-rt :deep(.wp-rt-dp-pipe) { color: var(--wp-warn, #fcd34d); opacity: 0.65; }
.wp-rt :deep(.wp-rt-dp-multi) {
color: #34d399;
background: color-mix(in oklab, #10b981 12%, transparent);
border-radius: 3px;
font-weight: 500;
}
.wp-rt :deep(.wp-rt-dp-weight) {
color: #fb923c;
background: color-mix(in oklab, #f97316 12%, transparent);
border-radius: 3px;
font-weight: 500;
}
.wp-rt :deep(.wp-rt-quantifier) {
color: var(--wp-info, #60a5fa);
background: color-mix(in oklab, var(--wp-info, #3b82f6) 14%, transparent);
border-radius: 3px;
font-weight: 500;
}
/* Focus / rest dual-mode chip chrome. */
.wp-rt--focused :deep(.wp-rt-var),
.wp-rt--focused :deep(.wp-rt-ref) {
padding: 0;
box-shadow: inset 0 0 0 1px var(--wp-accent-500, #8b5cf6);
}
.wp-rt--rest :deep(.wp-rt-var),
.wp-rt--rest :deep(.wp-rt-ref) {
padding: 0 4px;
border-radius: 4px;
}
.wp-rt--rest :deep(.wp-rt-dp-multi),
.wp-rt--rest :deep(.wp-rt-dp-weight),
.wp-rt--rest :deep(.wp-rt-quantifier) {
padding: 0 3px;
}
/* Autocomplete popover -------------------------------------------------- */
.wp-rt-suggestions {
position: absolute;
left: 8px;
right: 8px;
top: calc(100% + 4px);
z-index: 30;
background: var(--wp-bg-1, #11111b);
border: 1px solid var(--wp-border-strong, rgba(255, 255, 255, 0.14));
border-radius: 8px;
padding: 4px;
box-shadow: var(--wp-shadow, 0 4px 16px rgba(0, 0, 0, 0.35));
display: flex;
flex-direction: column;
gap: 1px;
max-height: 240px;
overflow-y: auto;
}
.wp-rt-suggestions--up {
top: auto;
bottom: calc(100% + 4px);
}
.wp-rt-suggestions__head {
display: flex;
gap: 8px;
padding: 4px 8px;
font-size: 11px;
color: var(--wp-text-muted, #a1a1ad);
border-bottom: 1px solid var(--wp-border, rgba(255, 255, 255, 0.08));
margin-bottom: 2px;
}
.wp-rt-suggestions__query {
font-family: var(--wp-font-mono, ui-monospace, monospace);
color: var(--wp-accent-text, #c4b5fd);
}
.wp-rt-suggestions__hint {
margin-left: auto;
opacity: 0.7;
}
.wp-rt-suggestions__item {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--wp-text, #e7e7ee);
cursor: pointer;
}
.wp-rt-suggestions__item[data-active] {
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 22%, transparent);
}
.wp-rt-suggestions__trigger {
color: var(--wp-accent-text, #c4b5fd);
}
</style>
@@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* RichTextPreview
*
* Read-only counterpart to {@link RichTextInput}. Renders the same
* mirror HTML produced by the shared `tokenizeRich` + `mirrorHtmlWithIdx`
* helpers, without any textarea or autocomplete chrome. Used in list-row
* expansions and any other place we want to show wildcard syntax with
* styled chips at rest.
*/
import { computed } from "vue";
import { tokenizeRich, mirrorHtmlWithIdx } from "../utils/richTokenize";
interface Props {
value: string;
}
const props = defineProps<Props>();
const tokens = computed(() => tokenizeRich(props.value || ""));
// 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.
const html = computed(() => mirrorHtmlWithIdx(tokens.value));
</script>
<template>
<span class="wp-rt-preview wp-rt--rest" v-html="html" />
</template>
<style scoped>
.wp-rt-preview {
display: inline;
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* Mirror the chip chrome used by RichTextInput at rest. Kept duplicated
(rather than imported) so the preview can stand alone in pages that don't
mount the input component. */
.wp-rt-preview :deep(.wp-rt-text) { color: var(--wp-text, #e7e7ee); }
.wp-rt-preview :deep(.wp-rt-comment) { color: var(--wp-text-dim, #6e6e7c); font-style: italic; }
.wp-rt-preview :deep(.wp-rt-escape) { color: var(--wp-text-muted, #a1a1ad); opacity: 0.7; }
.wp-rt-preview :deep(.wp-rt-tail) { color: transparent; }
.wp-rt-preview :deep(.wp-rt-var) {
color: var(--wp-accent-text-strong, var(--wp-accent-text, #c4b5fd));
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 15%, transparent);
padding: 0 4px;
border-radius: 4px;
font-weight: 500;
}
.wp-rt-preview :deep(.wp-rt-ref) {
color: var(--wp-kind-wildcard, #f0abfc);
background: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 12%, transparent);
padding: 0 4px;
border-radius: 4px;
font-weight: 500;
}
.wp-rt-preview :deep(.wp-rt-dp-brace) { color: var(--wp-warn, #fcd34d); font-weight: 600; }
.wp-rt-preview :deep(.wp-rt-dp-pipe) { color: var(--wp-warn, #fcd34d); opacity: 0.65; }
.wp-rt-preview :deep(.wp-rt-dp-multi) {
color: #34d399;
background: color-mix(in oklab, #10b981 12%, transparent);
padding: 0 3px;
border-radius: 3px;
font-weight: 500;
}
.wp-rt-preview :deep(.wp-rt-dp-weight) {
color: #fb923c;
background: color-mix(in oklab, #f97316 12%, transparent);
padding: 0 3px;
border-radius: 3px;
font-weight: 500;
}
.wp-rt-preview :deep(.wp-rt-quantifier) {
color: var(--wp-info, #60a5fa);
background: color-mix(in oklab, var(--wp-info, #3b82f6) 14%, transparent);
padding: 0 3px;
border-radius: 3px;
font-weight: 500;
}
</style>
+262
View File
@@ -0,0 +1,262 @@
/**
* Wildcard Pipeline rich-text tokenizer.
*
* Translated faithfully from the React reference at
* docs/design-handoff/wildcardpipeline/project/rich-input.jsx (function
* `tokenizeRich`). Recognises the following syntactic surface:
*
* $varname subject placeholder (var)
* @wildcard_name nested wildcard reference (ref)
* $$ / @@ literal-escape sequences
* {a|b|c} inline-choice block (dp-brace + dp-pipe)
* {N$$sep$$a|b|c} multi-select with optional separator
* {N::term} weighted option inside a brace block
* N#$var / N#@ref / N#{...} quantifier prefix
* # comment to end of line greyed-out comment line
*
* The tokenizer returns tokens covering the entire input contiguously
* (consumers can rely on `tokens[0].start === 0` and the final token's
* `end === text.length`). When a brace block contains nested syntax, sub-
* tokens are emitted with absolute indices so a flat consumer can render
* them in order without recursion.
*/
export type TokenKind =
| "text"
| "var"
| "ref"
| "dp-brace"
| "dp-pipe"
| "dp-multi"
| "dp-weight"
| "quantifier"
| "comment"
| "escape";
export interface TokenMeta {
name?: string;
weight?: number;
count?: number;
range?: string;
sep?: string | null;
}
export interface RichToken {
kind: TokenKind;
start: number;
end: number;
raw: string;
meta?: TokenMeta;
}
/**
* Walk `text` once and emit a flat list of {@link RichToken}s.
*
* Mirrors the original React implementation exactly so downstream
* mirror-rendering code can rely on identical token boundaries.
*/
export function tokenizeRich(text: string): RichToken[] {
const out: RichToken[] = [];
if (!text) return out;
const N = text.length;
let i = 0;
while (i < N) {
const ch = text[i];
// Comment: `#` at line start (or after whitespace) through end of line.
if (ch === "#" && (i === 0 || /\s/.test(text[i - 1]))) {
let j = i;
while (j < N && text[j] !== "\n") j++;
out.push({ kind: "comment", raw: text.slice(i, j), start: i, end: j });
i = j;
continue;
}
// Quantifier prefix `\d+#` followed by `$` or `{`.
if (ch >= "0" && ch <= "9") {
const m = text.slice(i).match(/^(\d+)#(?=[$@{])/);
if (m) {
out.push({
kind: "quantifier",
raw: m[0],
start: i,
end: i + m[0].length,
meta: { count: Number(m[1]) },
});
i += m[0].length;
continue;
}
// fall through — bare digits become plain text.
}
// Dynamic prompt block `{ ... }`.
if (ch === "{") {
let depth = 1;
let j = i + 1;
while (j < N && depth > 0) {
if (text[j] === "{") depth++;
else if (text[j] === "}") depth--;
if (depth > 0) j++;
}
if (depth === 0) {
const inner = text.slice(i + 1, j);
const fullEnd = j + 1;
const multi = inner.match(/^(-?\d+(?:-\d+)?)\$\$(?:([^$]*)\$\$)?/);
const headerLen = multi ? multi[0].length : 0;
const headerStart = i + 1;
const headerEnd = headerStart + headerLen;
out.push({ kind: "dp-brace", raw: "{", start: i, end: i + 1 });
if (multi) {
out.push({
kind: "dp-multi",
raw: multi[0],
start: headerStart,
end: headerEnd,
meta: { range: multi[1], sep: multi[2] || null },
});
}
let k = headerEnd;
let optStart = k;
let innerDepth = 0;
const flushOption = (endIdx: number): void => {
if (endIdx <= optStart) return;
const segText = text.slice(optStart, endIdx);
const w = segText.match(/^(\d+(?:\.\d+)?)::/);
if (w) {
out.push({
kind: "dp-weight",
raw: w[0],
start: optStart,
end: optStart + w[0].length,
meta: { weight: Number(w[1]) },
});
const subTokens = tokenizeRich(segText.slice(w[0].length));
const offset = optStart + w[0].length;
for (const t of subTokens) {
out.push({ ...t, start: t.start + offset, end: t.end + offset });
}
} else {
const subTokens = tokenizeRich(segText);
for (const t of subTokens) {
out.push({ ...t, start: t.start + optStart, end: t.end + optStart });
}
}
};
while (k < j) {
if (text[k] === "{") innerDepth++;
else if (text[k] === "}") innerDepth--;
else if (text[k] === "|" && innerDepth === 0) {
flushOption(k);
out.push({ kind: "dp-pipe", raw: "|", start: k, end: k + 1 });
optStart = k + 1;
}
k++;
}
flushOption(j);
out.push({ kind: "dp-brace", raw: "}", start: j, end: fullEnd });
i = fullEnd;
continue;
}
// Unmatched brace — fall through, treat as plain text.
}
// `$$` literal escape.
if (ch === "$" && text[i + 1] === "$") {
out.push({ kind: "escape", raw: "$$", start: i, end: i + 2 });
i += 2;
continue;
}
// `@@` literal escape.
if (ch === "@" && text[i + 1] === "@") {
out.push({ kind: "escape", raw: "@@", start: i, end: i + 2 });
i += 2;
continue;
}
// `$varname` token.
if (ch === "$") {
const m = text.slice(i + 1).match(/^([a-zA-Z_][a-zA-Z0-9_]*)/);
if (m) {
const raw = "$" + m[1];
out.push({
kind: "var",
raw,
start: i,
end: i + raw.length,
meta: { name: m[1] },
});
i += raw.length;
continue;
}
}
// `@varname` nested wildcard reference.
if (ch === "@") {
const m = text.slice(i + 1).match(/^([a-zA-Z_][a-zA-Z0-9_]*)/);
if (m) {
const raw = "@" + m[1];
out.push({
kind: "ref",
raw,
start: i,
end: i + raw.length,
meta: { name: m[1] },
});
i += raw.length;
continue;
}
}
// Plain text run — accumulate until the next character that *could* start
// a special token. Always advance at least one character to guarantee
// forward progress.
let j = i + 1;
while (j < N) {
const c = text[j];
if (c === "$" || c === "@" || c === "{") break;
if (c === "#" && (j === 0 || /\s/.test(text[j - 1]))) break;
if (c >= "0" && c <= "9" && /^\d+#[$@{]/.test(text.slice(j))) break;
j++;
}
out.push({ kind: "text", raw: text.slice(i, j), start: i, end: j });
i = j;
}
return out;
}
const HTML_ESCAPE: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => HTML_ESCAPE[c] ?? c);
}
/**
* Render a list of {@link RichToken}s to a flat HTML string.
*
* Each token becomes a `<span class="wp-rt-{kind}" data-idx="{i}">…</span>`,
* with content escaped against `<>&"'`. The `data-idx` attribute is the
* source index inside the token array — useful for caret-split logic that
* needs to locate the originating token from a DOM node.
*
* The output is intentionally pre-escaped so the consuming Vue mirror can
* use `v-html` safely (no user-controlled HTML survives the escape pass).
*/
export function mirrorHtmlWithIdx(tokens: RichToken[]): string {
let html = "";
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i];
html += `<span class="wp-rt-${t.kind}" data-idx="${i}">${escapeHtml(t.raw)}</span>`;
}
// Trailing zero-width span keeps the mirror's height in sync when the value
// ends with a newline (textareas reserve a line of space; our pre-wrap text
// does not, unless a glyph follows the final \n).
html += `<span class="wp-rt-tail">​</span>`;
return html;
}