Skip to content

Commit 69e57ed

Browse files
committed
fuzzywrap: avoid svelte html tag
1 parent 64a907c commit 69e57ed

File tree

3 files changed

+101
-57
lines changed

3 files changed

+101
-57
lines changed

frontend/src/AutocompleteInput.svelte

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
<script lang="ts">
2020
import type { KeySpec } from "./keyboard-shortcuts";
2121
import { keyboardShortcut } from "./keyboard-shortcuts";
22-
import { fuzzyfilter, fuzzywrap } from "./lib/fuzzy";
22+
import { fuzzyfilter, fuzzywrap, type FuzzyWrappedText } from "./lib/fuzzy";
2323
2424
interface Props {
2525
/** The currently entered value (bindable). */
@@ -79,18 +79,20 @@
7979
let extractedValue = $derived(
8080
input && valueExtractor ? valueExtractor(value, input) : value,
8181
);
82-
let filteredSuggestions: { suggestion: string; innerHTML: string }[] =
83-
$derived.by(() => {
84-
const filtered = fuzzyfilter(extractedValue, suggestions)
85-
.slice(0, 30)
86-
.map((suggestion) => ({
87-
suggestion,
88-
innerHTML: fuzzywrap(extractedValue, suggestion),
89-
}));
90-
return filtered.length === 1 && filtered[0]?.suggestion === extractedValue
91-
? []
92-
: filtered;
93-
});
82+
let filteredSuggestions: {
83+
suggestion: string;
84+
fuzzywrapped: FuzzyWrappedText;
85+
}[] = $derived.by(() => {
86+
const filtered = fuzzyfilter(extractedValue, suggestions)
87+
.slice(0, 30)
88+
.map((suggestion) => ({
89+
suggestion,
90+
fuzzywrapped: fuzzywrap(extractedValue, suggestion),
91+
}));
92+
return filtered.length === 1 && filtered[0]?.suggestion === extractedValue
93+
? []
94+
: filtered;
95+
});
9496
9597
$effect(() => {
9698
const msg = checkValidity ? checkValidity(value) : "";
@@ -189,7 +191,7 @@
189191
{/if}
190192
{#if filteredSuggestions.length}
191193
<ul {hidden} role="listbox" id={autocomple_id}>
192-
{#each filteredSuggestions as { innerHTML, suggestion }, i}
194+
{#each filteredSuggestions as { fuzzywrapped, suggestion }, i}
193195
<li
194196
role="option"
195197
aria-selected={i === index}
@@ -198,8 +200,13 @@
198200
mousedown(ev, suggestion);
199201
}}
200202
>
201-
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
202-
{@html innerHTML}
203+
{#each fuzzywrapped as [type, text]}
204+
{#if type === "text"}
205+
{text}
206+
{:else}
207+
<span>{text}</span>
208+
{/if}
209+
{/each}
203210
</li>
204211
{/each}
205212
</ul>
@@ -251,7 +258,7 @@
251258
background: transparent;
252259
}
253260
254-
li :global(span) {
261+
li span {
255262
height: 1.2em;
256263
padding: 0 0.05em;
257264
margin: 0 -0.05em;

frontend/src/lib/fuzzy.ts

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -48,25 +48,19 @@ export function fuzzyfilter(
4848
.sort((a, b) => b[1] - a[1])
4949
.map(([s]) => s);
5050
}
51-
const escapeChars: Record<string, string> = {
52-
'"': "&quot;",
53-
"'": "&#39;",
54-
"&": "&amp;",
55-
"<": "&lt;",
56-
">": "&gt;",
57-
};
58-
const e = (text: string) =>
59-
text.replace(/["'&<>]/g, (m) => escapeChars[m] ?? m);
51+
52+
export type FuzzyWrappedText = ["text" | "match", string][];
6053

6154
/**
6255
* Wrap fuzzy matched characters.
6356
*
6457
* Wrap all occurences of characters of `pattern` (in order) in `string` in
65-
* <span> tags.
58+
* tuples with a "match" marker (and the others as plain "text") to allow for
59+
* the matches to be wrapped in markers to highlight them in the HTML.
6660
*/
67-
export function fuzzywrap(pattern: string, text: string): string {
61+
export function fuzzywrap(pattern: string, text: string): FuzzyWrappedText {
6862
if (!pattern) {
69-
return e(text);
63+
return [["text", text]];
7064
}
7165
const casesensitive = pattern === pattern.toLowerCase();
7266
const exact = casesensitive
@@ -76,33 +70,50 @@ export function fuzzywrap(pattern: string, text: string): string {
7670
const before = text.slice(0, exact);
7771
const match = text.slice(exact, exact + pattern.length);
7872
const after = text.slice(exact + pattern.length);
79-
return `${e(before)}<span>${e(match)}</span>${e(after)}`;
73+
const result: FuzzyWrappedText = [];
74+
if (before) {
75+
result.push(["text", before]);
76+
}
77+
result.push(["match", match]);
78+
if (after) {
79+
result.push(["text", after]);
80+
}
81+
return result;
8082
}
83+
// current index into the pattern
8184
let pindex = 0;
82-
let inMatch = false;
83-
const result = [];
85+
// current unmatched string
86+
let plain: string | null = null;
87+
// current matched string
88+
let match: string | null = null;
89+
const result: FuzzyWrappedText = [];
8490
for (const char of text) {
8591
const search = pattern[pindex];
8692
if (char === search || char.toLowerCase() === search) {
87-
if (!inMatch) {
88-
result.push("<span>");
89-
inMatch = true;
93+
match = match != null ? match + char : char;
94+
if (plain != null) {
95+
result.push(["text", plain]);
96+
plain = null;
9097
}
91-
result.push(e(char));
9298
pindex += 1;
9399
} else {
94-
if (inMatch) {
95-
result.push("</span>");
96-
inMatch = false;
100+
plain = plain != null ? plain + char : char;
101+
if (match != null) {
102+
result.push(["match", match]);
103+
match = null;
97104
}
98-
result.push(e(char));
99105
}
100106
}
101107
if (pindex < pattern.length) {
102-
return e(text);
108+
return [["text", text]];
109+
}
110+
if (plain != null) {
111+
result.push(["text", plain]);
112+
plain = null;
103113
}
104-
if (inMatch) {
105-
result.push("</span>");
114+
if (match != null) {
115+
result.push(["match", match]);
116+
match = null;
106117
}
107-
return result.join("");
118+
return result;
108119
}

frontend/test/fuzzy.test.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,47 @@ test("fuzzy filter", () => {
3737
});
3838

3939
test("fuzzy wap", () => {
40-
assert.is(fuzzywrap("", "tenotest"), "tenotest");
41-
assert.is(fuzzywrap("", "<>tenotest"), "&lt;&gt;tenotest");
40+
assert.equal(fuzzywrap("", "tenotest"), [["text", "tenotest"]]);
41+
assert.equal(fuzzywrap("", "<>tenotest"), [["text", "<>tenotest"]]);
4242

43-
assert.is(fuzzywrap("test", "tenotest"), "teno<span>test</span>");
43+
assert.equal(fuzzywrap("test", "tenotest"), [
44+
["text", "teno"],
45+
["match", "test"],
46+
]);
4447
// no match for case sensitive pattern:
45-
assert.is(fuzzywrap("tesT", "test"), "test");
46-
assert.is(fuzzywrap("sdf", "nomatch"), "nomatch");
47-
assert.is(fuzzywrap("test", "tetest"), "te<span>test</span>");
48-
assert.is(fuzzywrap("test", "teTEST"), "te<span>TEST</span>");
49-
assert.is(fuzzywrap("a", "asdfasdf"), "<span>a</span>sdfasdf");
50-
assert.is(fuzzywrap("as", "asdfasdf"), "<span>as</span>dfasdf");
51-
assert.is(fuzzywrap("as", "as"), "<span>as</span>");
52-
assert.is(fuzzywrap("te", "tae"), "<span>t</span>a<span>e</span>");
53-
assert.is(fuzzywrap("te", "ta<e"), "<span>t</span>a&lt;<span>e</span>");
54-
assert.is(fuzzywrap("as", "<span>as"), "&lt;span&gt;<span>as</span>");
48+
assert.equal(fuzzywrap("tesT", "test"), [["text", "test"]]);
49+
assert.equal(fuzzywrap("sdf", "nomatch"), [["text", "nomatch"]]);
50+
assert.equal(fuzzywrap("test", "tetest"), [
51+
["text", "te"],
52+
["match", "test"],
53+
]);
54+
assert.equal(fuzzywrap("test", "teTEST"), [
55+
["text", "te"],
56+
["match", "TEST"],
57+
]);
58+
assert.equal(fuzzywrap("a", "asdfasdf"), [
59+
["match", "a"],
60+
["text", "sdfasdf"],
61+
]);
62+
assert.equal(fuzzywrap("as", "asdfasdf"), [
63+
["match", "as"],
64+
["text", "dfasdf"],
65+
]);
66+
assert.equal(fuzzywrap("as", "as"), [["match", "as"]]);
67+
assert.equal(fuzzywrap("te", "tae"), [
68+
["match", "t"],
69+
["text", "a"],
70+
["match", "e"],
71+
]);
72+
assert.equal(fuzzywrap("te", "ta<e"), [
73+
["match", "t"],
74+
["text", "a<"],
75+
["match", "e"],
76+
]);
77+
assert.equal(fuzzywrap("as", "<span>as"), [
78+
["text", "<span>"],
79+
["match", "as"],
80+
]);
5581
});
5682

5783
test.run();

0 commit comments

Comments
 (0)