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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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("<");
|
||||
expect(html).toContain(">");
|
||||
expect(html).toContain("&");
|
||||
expect(html).toContain(""");
|
||||
expect(html).toContain("'");
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user