feat(manager): surface-conditional @ autocomplete + warning highlights
- Add `surface: SurfaceKind` prop (default "wildcard") to RichTextInput +
RichTextPreview. When surface !== "wildcard", @ autocomplete is disabled;
manually-typed @{uuid} tokens still tokenise/render but with muted styling
via the new `.wp-rt-ref--ignored` CSS class.
- Add `warnings: ResolveWarning[]` prop to both components. Each warning
renders a `.wp-rt-warn-marker` span with `data-warning-position` + severity
CSS class (`wp-rt-warn-info` / `wp-rt-warn-warn` / `wp-rt-warn-error`).
- Add `uuidToName: Map<string, string>` prop for UUID→display-name lookup.
Mirror layer renders `@{uuid}` refs as `@name` when the map resolves them.
- Autocomplete insert for @ trigger now writes `@{uuid}` raw text (not @name).
- Mirror HTML builder inlined in component (no longer delegates to the shared
mirrorHtmlWithIdx so we can branch on surface + apply per-token overrides).
- CSS: new warn-marker rules + wp-rt-ref--ignored muted chip added to
rich-text.css (shared by RichTextInput and RichTextPreview).
Tests:
- Create src/manager/__tests__/components/RichTextInput.warnings.test.ts
with 20 new tests covering warning markers, severity→class mapping, surface
autocomplete gating, UUID name display, and RichTextPreview parity.
- Un-skip "paints chip markup" test in RichTextInput.test.ts, updated to use
@{8hex} UUID form (old @name form no longer tokenised as ref).
- Mark "renders # comment lines greyed out" permanently skipped (OBSOLETE) —
comment token kind was removed in the locked grammar; test can never pass.
- Wildcards.test.ts skip comment updated with DEFERRED rationale (Wildcards.vue
syntaxView() uses slug keys while buildWildcardGraph uses UUID keys — needs
its own follow-up PR to align).
- Delete 3 dead skipped tests from richTokenize.test.ts that tested removed
grammar features (@name short form, quantifier N#$var, # comment lines);
those token kinds no longer exist in the tokenizer or the TokenKind union.
Rationale left as inline comments where the tests were.
This commit is contained in:
@@ -38,11 +38,10 @@ describe("RichTextInput.vue", () => {
|
||||
expect(wrap.find("textarea").attributes("rows")).toBe("6");
|
||||
});
|
||||
|
||||
// TODO(syntax-task-19): re-enable after RichTextInput is updated for @{8hex} UUID refs
|
||||
// The test uses the legacy @name short form which is no longer tokenised as ref.
|
||||
it.skip("paints chip markup in the mirror layer for $var/@ref/{a|b|c}", () => {
|
||||
it("paints chip markup in the mirror layer for $var/@{uuid}/{a|b|c}", () => {
|
||||
// Uses @{8hex} UUID form — the locked grammar. Legacy @name falls to text.
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: { modelValue: "$person sees @colors of {red|blue}" },
|
||||
props: { modelValue: "$person sees @{1a2b3c4d} of {red|blue}" },
|
||||
});
|
||||
const mirror = wrap.find(".wp-rt__mirror");
|
||||
expect(mirror.exists()).toBe(true);
|
||||
@@ -50,7 +49,9 @@ describe("RichTextInput.vue", () => {
|
||||
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");
|
||||
// dp-pipe is no longer emitted as a separate token; dp-brace covers the whole block.
|
||||
// Confirm the brace block is present via dp-brace class:
|
||||
expect(html).toContain("wp-rt-dp-brace");
|
||||
});
|
||||
|
||||
it("renders $$ escapes as escape spans (not vars)", () => {
|
||||
@@ -63,9 +64,10 @@ describe("RichTextInput.vue", () => {
|
||||
expect(html).not.toMatch(/wp-rt-var[^]*\$\$/);
|
||||
});
|
||||
|
||||
// TODO(syntax-task-19): re-enable after comment syntax is removed from RichTextInput styling
|
||||
// The comment token kind is no longer emitted; # lines are plain text in the new grammar.
|
||||
it.skip("renders # comment lines greyed out", () => {
|
||||
// OBSOLETE (task-19 confirmed): Comment token kind was removed in the locked grammar.
|
||||
// `# lines` are now plain text. This test can never pass with the current tokenizer.
|
||||
// Keeping skipped rather than deleted so the intent is visible in git history.
|
||||
it.skip("renders # comment lines greyed out [OBSOLETE — comment syntax removed]", () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: { modelValue: "# noted", multiline: true },
|
||||
});
|
||||
|
||||
@@ -74,9 +74,10 @@ describe("Wildcards.vue", () => {
|
||||
expect(wrap.text()).toContain("No wildcards yet");
|
||||
});
|
||||
|
||||
// TODO(syntax-task-19): re-enable after wildcardSyntax.ts + Wildcards.vue UUID-graph rewrite
|
||||
// The test uses legacy @name short form refs (e.g. "@hat") which are no longer tokenised
|
||||
// as ref tokens. The syntax-pill logic depends on ref detection via wildcardSyntax.ts.
|
||||
// DEFERRED (task-19): The test uses legacy @name short form refs (e.g. "@hat") which are no
|
||||
// longer tokenised as ref tokens. Additionally Wildcards.vue syntaxView() uses wildcardVarName
|
||||
// (string slug) as the graph key, while buildWildcardGraph keys by UUID (mod.id). A follow-up
|
||||
// task must align Wildcards.vue to the UUID graph and update the fixture data to use @{8hex}.
|
||||
it.skip("renders syntax indicator pills for refs / inline / referenced-by", async () => {
|
||||
// Two wildcards: `outfit` references `@hat` (outgoing) and contains an
|
||||
// inline `{a|b}` choice; `hat` is referenced by `outfit` (incoming).
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
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";
|
||||
import type { ResolveWarning } from "../../utils/resolveTokens";
|
||||
|
||||
const tick = flushPromises;
|
||||
|
||||
// Helper to build a minimal ResolveWarning.
|
||||
function warn(
|
||||
position: number,
|
||||
severity: "info" | "warn" | "error",
|
||||
message = "test warning",
|
||||
): ResolveWarning {
|
||||
return {
|
||||
type: "test",
|
||||
severity,
|
||||
module_id: "00000000",
|
||||
source_field: "value",
|
||||
position,
|
||||
token_index: null,
|
||||
detail: {},
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Warning marker rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("RichTextInput — warning markers", () => {
|
||||
it("renders a marker span for each warning with data-warning-position", () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "hello $name",
|
||||
warnings: [warn(6, "warn"), warn(0, "info")],
|
||||
},
|
||||
});
|
||||
const markers = wrap.findAll(".wp-rt-warn-marker");
|
||||
expect(markers).toHaveLength(2);
|
||||
const positions = markers.map((m) => m.attributes("data-warning-position"));
|
||||
expect(positions).toContain("6");
|
||||
expect(positions).toContain("0");
|
||||
});
|
||||
|
||||
it("applies severity → CSS class: info → wp-rt-warn-info", () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "x",
|
||||
warnings: [warn(0, "info")],
|
||||
},
|
||||
});
|
||||
const marker = wrap.find(".wp-rt-warn-marker");
|
||||
expect(marker.classes()).toContain("wp-rt-warn-info");
|
||||
expect(marker.classes()).not.toContain("wp-rt-warn-warn");
|
||||
expect(marker.classes()).not.toContain("wp-rt-warn-error");
|
||||
});
|
||||
|
||||
it("applies severity → CSS class: warn → wp-rt-warn-warn", () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "x",
|
||||
warnings: [warn(0, "warn")],
|
||||
},
|
||||
});
|
||||
const marker = wrap.find(".wp-rt-warn-marker");
|
||||
expect(marker.classes()).toContain("wp-rt-warn-warn");
|
||||
});
|
||||
|
||||
it("applies severity → CSS class: error → wp-rt-warn-error", () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "x",
|
||||
warnings: [warn(0, "error")],
|
||||
},
|
||||
});
|
||||
const marker = wrap.find(".wp-rt-warn-marker");
|
||||
expect(marker.classes()).toContain("wp-rt-warn-error");
|
||||
});
|
||||
|
||||
it("renders no warning markers when warnings prop is empty", () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: { modelValue: "x", warnings: [] },
|
||||
});
|
||||
expect(wrap.findAll(".wp-rt-warn-marker")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("warning marker title matches the warning message", () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "x",
|
||||
warnings: [warn(0, "error", "ref not found")],
|
||||
},
|
||||
});
|
||||
const marker = wrap.find(".wp-rt-warn-marker");
|
||||
expect(marker.attributes("title")).toBe("ref not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Surface-conditional autocomplete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function popoverExists(): boolean {
|
||||
return document.querySelector(".wp-rt-suggestions") !== null;
|
||||
}
|
||||
|
||||
describe("RichTextInput — surface prop", () => {
|
||||
it("defaults surface to wildcard — @ autocomplete enabled", async () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "",
|
||||
refSuggestions: ["1a2b3c4d"],
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const input = wrap.find("input");
|
||||
const el = input.element as HTMLInputElement;
|
||||
el.value = "@1";
|
||||
el.setSelectionRange(2, 2);
|
||||
await input.trigger("input");
|
||||
await tick();
|
||||
expect(popoverExists()).toBe(true);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("surface=wildcard: @ autocomplete shows suggestions", async () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "",
|
||||
surface: "wildcard" as const,
|
||||
refSuggestions: ["aabbccdd", "11223344"],
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const input = wrap.find("input");
|
||||
const el = input.element as HTMLInputElement;
|
||||
el.value = "@aa";
|
||||
el.setSelectionRange(3, 3);
|
||||
await input.trigger("input");
|
||||
await tick();
|
||||
expect(popoverExists()).toBe(true);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("surface=combine: @ autocomplete is disabled", async () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "",
|
||||
surface: "combine" as const,
|
||||
refSuggestions: ["aabbccdd"],
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const input = wrap.find("input");
|
||||
const el = input.element as HTMLInputElement;
|
||||
el.value = "@aa";
|
||||
el.setSelectionRange(3, 3);
|
||||
await input.trigger("input");
|
||||
await tick();
|
||||
expect(popoverExists()).toBe(false);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("surface=derivation: @ autocomplete is disabled", async () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "",
|
||||
surface: "derivation" as const,
|
||||
refSuggestions: ["aabbccdd"],
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const input = wrap.find("input");
|
||||
const el = input.element as HTMLInputElement;
|
||||
el.value = "@aa";
|
||||
el.setSelectionRange(3, 3);
|
||||
await input.trigger("input");
|
||||
await tick();
|
||||
expect(popoverExists()).toBe(false);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("surface=assembler: @ autocomplete is disabled", async () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "",
|
||||
surface: "assembler" as const,
|
||||
refSuggestions: ["aabbccdd"],
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const input = wrap.find("input");
|
||||
const el = input.element as HTMLInputElement;
|
||||
el.value = "@aa";
|
||||
el.setSelectionRange(3, 3);
|
||||
await input.trigger("input");
|
||||
await tick();
|
||||
expect(popoverExists()).toBe(false);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("non-wildcard surface: $ autocomplete still works", async () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "",
|
||||
surface: "combine" as const,
|
||||
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();
|
||||
expect(popoverExists()).toBe(true);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("non-wildcard surface: @{uuid} tokens in mirror get wp-rt-ref--ignored class", () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "@{1a2b3c4d} text",
|
||||
surface: "combine" as const,
|
||||
},
|
||||
});
|
||||
const mirror = wrap.find(".wp-rt__mirror");
|
||||
expect(mirror.html()).toContain("wp-rt-ref--ignored");
|
||||
});
|
||||
|
||||
it("wildcard surface: @{uuid} tokens in mirror do NOT get wp-rt-ref--ignored class", () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "@{1a2b3c4d} text",
|
||||
surface: "wildcard" as const,
|
||||
},
|
||||
});
|
||||
const mirror = wrap.find(".wp-rt__mirror");
|
||||
expect(mirror.html()).not.toContain("wp-rt-ref--ignored");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UUID → name display in mirror
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("RichTextInput — UUID name display", () => {
|
||||
it("renders @name display form when uuidToName provides a match", () => {
|
||||
const map = new Map([["1a2b3c4d", "hat"]]);
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "wear @{1a2b3c4d}",
|
||||
uuidToName: map,
|
||||
},
|
||||
});
|
||||
const mirror = wrap.find(".wp-rt__mirror");
|
||||
expect(mirror.html()).toContain("@hat");
|
||||
});
|
||||
|
||||
it("falls back to raw @{uuid} when uuid not in map", () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "wear @{1a2b3c4d}",
|
||||
uuidToName: new Map(),
|
||||
},
|
||||
});
|
||||
const mirror = wrap.find(".wp-rt__mirror");
|
||||
expect(mirror.html()).toContain("@{1a2b3c4d}");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RichTextPreview — surface + warnings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("RichTextPreview — surface + warnings", () => {
|
||||
it("renders warning markers with data-warning-position", () => {
|
||||
const wrap = mount(RichTextPreview, {
|
||||
props: {
|
||||
value: "hello",
|
||||
warnings: [warn(0, "warn"), warn(3, "error")],
|
||||
},
|
||||
});
|
||||
const markers = wrap.findAll(".wp-rt-warn-marker");
|
||||
expect(markers).toHaveLength(2);
|
||||
const positions = markers.map((m) => m.attributes("data-warning-position"));
|
||||
expect(positions).toContain("0");
|
||||
expect(positions).toContain("3");
|
||||
});
|
||||
|
||||
it("non-wildcard surface: ref tokens get wp-rt-ref--ignored class", () => {
|
||||
const wrap = mount(RichTextPreview, {
|
||||
props: {
|
||||
value: "@{1a2b3c4d}",
|
||||
surface: "combine" as const,
|
||||
},
|
||||
});
|
||||
expect(wrap.html()).toContain("wp-rt-ref--ignored");
|
||||
});
|
||||
|
||||
it("wildcard surface: ref tokens do not get wp-rt-ref--ignored class", () => {
|
||||
const wrap = mount(RichTextPreview, {
|
||||
props: {
|
||||
value: "@{1a2b3c4d}",
|
||||
surface: "wildcard" as const,
|
||||
},
|
||||
});
|
||||
expect(wrap.html()).not.toContain("wp-rt-ref--ignored");
|
||||
});
|
||||
|
||||
it("renders display name from uuidToName map", () => {
|
||||
const wrap = mount(RichTextPreview, {
|
||||
props: {
|
||||
value: "@{deadbeef}",
|
||||
uuidToName: new Map([["deadbeef", "outfit"]]),
|
||||
},
|
||||
});
|
||||
expect(wrap.html()).toContain("@outfit");
|
||||
});
|
||||
});
|
||||
@@ -22,15 +22,9 @@ describe("tokenizeRich", () => {
|
||||
expect(v?.end).toBe(9);
|
||||
});
|
||||
|
||||
// TODO(syntax-task-19): re-enable after wildcardSyntax.ts UUID-graph rewrite
|
||||
// The old @name short form is no longer recognised as ref; it falls through to text.
|
||||
it.skip("recognises @ref tokens (legacy @name short form)", () => {
|
||||
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");
|
||||
});
|
||||
// DELETED (task-19): "recognises @ref tokens (legacy @name short form)" — the @name
|
||||
// short form was removed from the grammar in the locked tokenizer (Tasks 3-7). The
|
||||
// test was already skipped with a TODO; deleting it here rather than leaving dead code.
|
||||
|
||||
it("recognises @{8hex} UUID ref tokens", () => {
|
||||
const tokens = tokenizeRich("see @{1a2b3c4d}");
|
||||
@@ -94,27 +88,13 @@ describe("tokenizeRich", () => {
|
||||
expect(m.meta?.branches).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
// TODO(syntax-task-19): re-enable after quantifier is re-evaluated for the new grammar
|
||||
// The quantifier prefix N#$var is no longer recognised; it becomes plain text.
|
||||
it.skip("recognises a quantifier prefix N#$var (legacy)", () => {
|
||||
const tokens = tokenizeRich("5#$person");
|
||||
// Cast to string so TypeScript does not reject the removed kind literal.
|
||||
const q = tokens.find((t) => (t.kind as string) === "quantifier");
|
||||
const v = tokens.find((t) => t.kind === "var");
|
||||
expect(q).toBeDefined();
|
||||
expect(q?.raw).toBe("5#");
|
||||
expect((q?.meta as Record<string, unknown>)?.count).toBe(5);
|
||||
expect(v?.raw).toBe("$person");
|
||||
});
|
||||
// DELETED (task-19): "recognises a quantifier prefix N#$var (legacy)" — the quantifier
|
||||
// N#$var syntax was removed in the locked grammar (Tasks 3-5). The kind "quantifier" no
|
||||
// longer exists in TokenKind. Deleting rather than keeping dead skipped test.
|
||||
|
||||
// TODO(syntax-task-19): re-enable after comment syntax is re-evaluated for the new grammar
|
||||
// The # comment line syntax is no longer recognised.
|
||||
it.skip("recognises # comment lines (legacy)", () => {
|
||||
const tokens = tokenizeRich("# this is a comment\nrest");
|
||||
// Cast to string so TypeScript does not reject the removed kind literal.
|
||||
expect(tokens[0].kind as string).toBe("comment");
|
||||
expect(tokens[0].raw).toBe("# this is a comment");
|
||||
});
|
||||
// DELETED (task-19): "recognises # comment lines (legacy)" — the # comment line syntax
|
||||
// was removed in the locked grammar. The kind "comment" no longer exists in TokenKind.
|
||||
// Deleting rather than keeping dead skipped test.
|
||||
|
||||
it("treats # at line start as plain text (no comment syntax)", () => {
|
||||
const tokens = tokenizeRich("# noted");
|
||||
|
||||
@@ -15,25 +15,33 @@
|
||||
* Reference: docs/design-handoff/wildcardpipeline/project/rich-input.jsx.
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { tokenizeRich, mirrorHtmlWithIdx } from "../utils/richTokenize";
|
||||
import { tokenizeRich } from "../utils/richTokenize";
|
||||
import type { SurfaceKind, ResolveWarning } from "../utils/resolveTokens";
|
||||
|
||||
interface Props {
|
||||
modelValue: string;
|
||||
surface?: SurfaceKind;
|
||||
warnings?: ResolveWarning[];
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
varSuggestions?: string[];
|
||||
refSuggestions?: string[];
|
||||
/** Map from UUID to display name; used to render `@{uuid}` refs as human labels. */
|
||||
uuidToName?: Map<string, string>;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
surface: "wildcard",
|
||||
warnings: () => [],
|
||||
multiline: false,
|
||||
rows: 4,
|
||||
placeholder: "",
|
||||
varSuggestions: () => [],
|
||||
refSuggestions: () => [],
|
||||
uuidToName: () => new Map(),
|
||||
ariaLabel: undefined,
|
||||
disabled: false,
|
||||
});
|
||||
@@ -68,11 +76,49 @@ 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));
|
||||
// When `surface !== "wildcard"`, ref tokens get an extra "ignored" class
|
||||
// so they render with muted styling.
|
||||
const mirrorHtml = computed(() => {
|
||||
const toks = tokens.value;
|
||||
const isWildcard = props.surface === "wildcard";
|
||||
const map = props.uuidToName;
|
||||
let html = "";
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
const t = toks[i];
|
||||
let cls = `wp-rt-${t.kind}`;
|
||||
let display = 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
|
||||
));
|
||||
}
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
html += '<span class="wp-rt-tail">​</span>';
|
||||
return html;
|
||||
});
|
||||
|
||||
// --- Suggestion list filtering. ---
|
||||
// `@` autocomplete is only available in the "wildcard" surface.
|
||||
const acItems = computed(() => {
|
||||
if (!acOpen.value) return [];
|
||||
if (acTrigger.value === "@" && props.surface !== "wildcard") return [];
|
||||
const pool = acTrigger.value === "@" ? props.refSuggestions : props.varSuggestions;
|
||||
const q = acQuery.value.toLowerCase();
|
||||
return pool
|
||||
@@ -141,6 +187,11 @@ function onInput(e: Event): void {
|
||||
const caret = target.selectionStart ?? next.length;
|
||||
const hit = probeAutocomplete(next, caret);
|
||||
if (hit) {
|
||||
// Gate `@` autocomplete — only available in the "wildcard" surface.
|
||||
if (hit.trigger === "@" && props.surface !== "wildcard") {
|
||||
acOpen.value = false;
|
||||
return;
|
||||
}
|
||||
acOpen.value = true;
|
||||
acStart.value = hit.start;
|
||||
acQuery.value = hit.query;
|
||||
@@ -158,6 +209,11 @@ function refreshAutocompleteFromCaret(): void {
|
||||
const caret = el.selectionStart ?? 0;
|
||||
const hit = probeAutocomplete(props.modelValue || "", caret);
|
||||
if (hit) {
|
||||
// Gate `@` autocomplete — only available in the "wildcard" surface.
|
||||
if (hit.trigger === "@" && props.surface !== "wildcard") {
|
||||
acOpen.value = false;
|
||||
return;
|
||||
}
|
||||
acOpen.value = true;
|
||||
acStart.value = hit.start;
|
||||
acQuery.value = hit.query;
|
||||
@@ -207,7 +263,11 @@ function applyAutocomplete(label: string | undefined): void {
|
||||
const caret = el.selectionStart ?? value.length;
|
||||
const before = value.slice(0, acStart.value);
|
||||
const after = value.slice(caret);
|
||||
const inserted = acTrigger.value + label;
|
||||
// For `@` trigger in wildcard surface, `label` is a UUID; wrap it in `@{uuid}`.
|
||||
// For `$` trigger, insert `$name` as before.
|
||||
const inserted = acTrigger.value === "@"
|
||||
? `@{${label}}`
|
||||
: acTrigger.value + label;
|
||||
const next = before + inserted + after;
|
||||
emit("update:modelValue", next);
|
||||
acOpen.value = false;
|
||||
@@ -286,7 +346,7 @@ onBeforeUnmount(() => {
|
||||
: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. -->
|
||||
`v-html` is safe because the computed mirrorHtml escapes all token text. -->
|
||||
<div
|
||||
ref="mirrorEl"
|
||||
class="wp-rt__mirror"
|
||||
@@ -295,6 +355,24 @@ onBeforeUnmount(() => {
|
||||
v-html="mirrorHtml"
|
||||
/>
|
||||
|
||||
<!-- Warning markers overlay. Each marker is a zero-width inline element
|
||||
anchored at the UTF-16 offset corresponding to the warning position.
|
||||
`data-warning-position` records the original code-point index for tests. -->
|
||||
<div
|
||||
v-if="warnings.length > 0"
|
||||
class="wp-rt__warnings"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
v-for="w in warnings"
|
||||
:key="`${w.position}-${w.severity}`"
|
||||
class="wp-rt-warn-marker"
|
||||
:class="`wp-rt-warn-${w.severity}`"
|
||||
:data-warning-position="w.position"
|
||||
:title="w.message"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="multiline"
|
||||
ref="inputEl"
|
||||
@@ -467,6 +545,15 @@ onBeforeUnmount(() => {
|
||||
/* Token span chrome (.wp-rt-var / .wp-rt-ref / .wp-rt-dp-* / quantifier
|
||||
colour + chip backgrounds + focus/rest padding) lives in the global
|
||||
styles/rich-text.css so RichTextInput and RichTextPreview stay in sync. */
|
||||
|
||||
/* Warning markers overlay — sits atop the mirror, pointer-events: none so
|
||||
it does not block textarea interaction. */
|
||||
.wp-rt__warnings {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -9,23 +9,72 @@
|
||||
* styled chips at rest.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { tokenizeRich, mirrorHtmlWithIdx } from "../utils/richTokenize";
|
||||
import { tokenizeRich } from "../utils/richTokenize";
|
||||
import type { SurfaceKind, ResolveWarning } from "../utils/resolveTokens";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
surface?: SurfaceKind;
|
||||
warnings?: ResolveWarning[];
|
||||
/** Map from UUID to display name; used to render `@{uuid}` refs as human labels. */
|
||||
uuidToName?: Map<string, string>;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
surface: "wildcard",
|
||||
warnings: () => [],
|
||||
uuidToName: () => new Map(),
|
||||
});
|
||||
|
||||
const HTML_ESC: Record<string, string> = {
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
};
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => HTML_ESC[c] ?? c);
|
||||
}
|
||||
|
||||
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));
|
||||
// SAFE: all token text is HTML-escaped before concatenation.
|
||||
// `@{uuid}` ref tokens are rendered with a human-readable display name
|
||||
// when `uuidToName` provides one. Non-wildcard surfaces get a muted class.
|
||||
const html = computed(() => {
|
||||
const toks = tokens.value;
|
||||
const isWildcard = props.surface === "wildcard";
|
||||
const map = props.uuidToName;
|
||||
let out = "";
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
const t = toks[i];
|
||||
let cls = `wp-rt-${t.kind}`;
|
||||
let display: string;
|
||||
if (t.kind === "ref") {
|
||||
if (!isWildcard) cls += " wp-rt-ref--ignored";
|
||||
const uuid = t.meta?.uuid;
|
||||
if (uuid && map.has(uuid)) {
|
||||
display = "@" + esc(map.get(uuid)!);
|
||||
} else {
|
||||
display = esc(t.raw);
|
||||
}
|
||||
} else {
|
||||
display = esc(t.raw);
|
||||
}
|
||||
out += `<span class="${cls}" data-idx="${i}">${display}</span>`;
|
||||
}
|
||||
out += '<span class="wp-rt-tail">​</span>';
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="wp-rt-preview wp-rt--rest" v-html="html" />
|
||||
<span
|
||||
v-for="w in warnings"
|
||||
:key="`${w.position}-${w.severity}`"
|
||||
class="wp-rt-warn-marker"
|
||||
:class="`wp-rt-warn-${w.severity}`"
|
||||
:data-warning-position="w.position"
|
||||
:title="w.message"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -120,6 +120,52 @@
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
/* ── Warning marker spans ── *
|
||||
* Emitted by RichTextInput + RichTextPreview when `warnings` prop is set.
|
||||
* Markers are zero-width spans with a coloured bottom border and a small
|
||||
* icon fragment so they are visible without disrupting text flow.
|
||||
* Severity → class mapping:
|
||||
* info → wp-rt-warn-info (blue)
|
||||
* warn → wp-rt-warn-warn (amber)
|
||||
* error → wp-rt-warn-error (red)
|
||||
* -------------------------------------------------------------------- */
|
||||
.wp-rt-warn-marker {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
border-left: 2px solid transparent;
|
||||
border-bottom: 2px solid transparent;
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.wp-rt-warn-info {
|
||||
border-color: var(--wp-info, #60a5fa);
|
||||
border-bottom-color: var(--wp-info, #60a5fa);
|
||||
}
|
||||
.wp-rt-warn-warn {
|
||||
border-color: var(--wp-warn, #fcd34d);
|
||||
border-bottom-color: var(--wp-warn, #fcd34d);
|
||||
}
|
||||
.wp-rt-warn-error {
|
||||
border-color: var(--wp-error, #f87171);
|
||||
border-bottom-color: var(--wp-error, #f87171);
|
||||
}
|
||||
|
||||
/* ── Muted ref chip — used when surface !== "wildcard" ── *
|
||||
* Inherits the same chip shape as .wp-rt-ref but tones down the colour
|
||||
* to signal that `@{uuid}` tokens are ignored on this surface.
|
||||
* -------------------------------------------------------------------- */
|
||||
.wp-rt .wp-rt-ref--ignored,
|
||||
.wp-rt-preview .wp-rt-ref--ignored {
|
||||
color: var(--wp-text-dim, #6e6e7c);
|
||||
background: rgba(107, 114, 128, 0.12);
|
||||
background: color-mix(in oklab, var(--wp-text-muted, #6e6e7c) 12%, transparent);
|
||||
box-shadow: inset 0 0 0 1px rgba(107, 114, 128, 0.22);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-text-muted, #6e6e7c) 22%, transparent);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ── Light-mode overrides: rich-text token chrome ── */
|
||||
.wp-theme-light .wp-rt .wp-rt-var,
|
||||
.wp-theme-light .wp-rt-preview .wp-rt-var {
|
||||
|
||||
Reference in New Issue
Block a user