Skip to content

Commit 4f6c290

Browse files
[GSoC] Quick navigation algorithm (#372)
* feat - Initial commit with quick navigation algorithm * Fuzzy match algorithm made with regex only * Tests for quick navigation component * feat: Fuzzy search algorithm and basic functionality for Quick navigation * Introduced a new component "QuickNavigationModal" that serves as UI for the new searching functionality. * New regex pattern to match symbol titles by fuzzy and string match. * Data store to control the Quick navigation modal from anywhere on the app without having to pass the data through multiple child compoents. This change is under the quickNavigation feature flag. * fix: Reversed changes of package-lock.json * fix: `inputCoincidencesRegexPattern` now only matches the first appearance of a character * fix: Remove hard coded items inside flatten Index state + fixed typo * fix: Fuzzy match highlighting * Refactor of match highlighter for Safari support using `QuickNavigationHighlighter` component * Code quality improvements suggested on pr review * fix: Code cleanup and readability improvements Co-authored-by: Marina Aísa <[email protected]>
1 parent fe8a8dc commit 4f6c290

File tree

9 files changed

+485
-59
lines changed

9 files changed

+485
-59
lines changed

app/public/theme-settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@
5454
},
5555
"features": {
5656
"docs": {
57+
"quickNavigation": {
58+
"enable": false
59+
}
5760
}
5861
}
5962
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!--
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2022 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
-->
10+
11+
<template>
12+
<SVGIcon viewBox="0 0 14 14" class="magnifier-icon">
13+
<path d="M15.0013 14.0319L10.9437 9.97424C11.8165 8.88933 12.2925 7.53885 12.2929 6.14645C12.2929 2.75841 9.53449 0 6.14645 0C2.75841 0 0 2.75841 0 6.14645C0 9.53449 2.75841 12.2929 6.14645 12.2929C7.57562 12.2929 8.89486 11.7932 9.94425 10.9637L14.0019 15.0213L15.0013 14.0319ZM6.13645 11.0736C4.83315 11.071 3.58399 10.5521 2.66241 9.63048C1.74084 8.70891 1.22194 7.45974 1.2193 6.15644C1.2193 3.44801 3.41802 1.23928 6.13645 1.23928C8.85488 1.23928 11.0536 3.44801 11.0536 6.15644C11.0636 8.86488 8.85488 11.0736 6.13645 11.0736Z"/>
14+
</SVGIcon>
15+
</template>
16+
17+
<script>
18+
import SVGIcon from 'docc-render/components/SVGIcon.vue';
19+
20+
export default {
21+
name: 'MafnifierIcon',
22+
components: { SVGIcon },
23+
};
24+
</script>

src/components/Navigator.vue

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
</template>
4040

4141
<script>
42+
import QuickNavigationStore from 'docc-render/stores/QuickNavigationStore';
4243
import NavigatorCard from 'theme/components/Navigator/NavigatorCard.vue';
4344
import SpinnerIcon from 'theme/components/Icons/SpinnerIcon.vue';
4445
import NavigatorCardInner from 'docc-render/components/Navigator/NavigatorCardInner.vue';
@@ -74,6 +75,7 @@ export default {
7475
data() {
7576
return {
7677
INDEX_ROOT_KEY,
78+
store: QuickNavigationStore,
7779
};
7880
},
7981
props: {
@@ -110,6 +112,9 @@ export default {
110112
default: null,
111113
},
112114
},
115+
provide() {
116+
return { store: this.store };
117+
},
113118
computed: {
114119
// gets the paths for each parent in the breadcrumbs
115120
parentTopicReferences({ references, parentTopicIdentifiers }) {
@@ -139,9 +144,11 @@ export default {
139144
* Recomputes the list of flat children.
140145
* @return NavigatorFlatItem[]
141146
*/
142-
flatChildren: ({ flattenNestedData, technology = {} }) => (
143-
flattenNestedData(technology.children || [], null, 0, technology.beta)
144-
),
147+
flatChildren: ({ flattenNestedData, technology = {}, store }) => {
148+
const flatIndex = flattenNestedData(technology.children || [], null, 0, technology.beta);
149+
store.setFlattenIndex(flatIndex);
150+
return flatIndex;
151+
},
145152
/**
146153
* The root item is always a module
147154
*/

src/components/Navigator/NavigatorCard.vue

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@
9292
@clear="clearFilters"
9393
/>
9494
</div>
95+
<div
96+
class="magnifier-icon"
97+
@click="store.toggleShowQuickNavigationModal()"
98+
v-if="enableQuickNavigation"
99+
>
100+
<MagnifierIcon/>
101+
</div>
95102
</div>
96103
</div>
97104
</div>
@@ -116,6 +123,9 @@ import keyboardNavigation from 'docc-render/mixins/keyboardNavigation';
116123
import { isEqual, last } from 'docc-render/utils/arrays';
117124
import { ChangeNames, ChangeNameToType } from 'docc-render/constants/Changes';
118125
import Badge from 'docc-render/components/Badge.vue';
126+
import MagnifierIcon from 'docc-render/components/Icons/MagnifierIcon.vue';
127+
import { getSetting } from 'docc-render/utils/theme-settings';
128+
import QuickNavigationStore from 'docc-render/stores/QuickNavigationStore';
119129
120130
const STORAGE_KEY = 'navigator.state';
121131
@@ -178,6 +188,7 @@ export default {
178188
Badge,
179189
FilterInput,
180190
SidenavIcon,
191+
MagnifierIcon,
181192
NavigatorCardInner,
182193
NavigatorCardItem,
183194
RecycleScroller,
@@ -241,6 +252,7 @@ export default {
241252
nodesToRender: [],
242253
activeUID: null,
243254
resetScroll: false,
255+
store: QuickNavigationStore,
244256
lastFocusTarget: null,
245257
NO_RESULTS,
246258
NO_CHILDREN,
@@ -452,6 +464,9 @@ export default {
452464
hasNodes: ({ nodesToRender }) => !!nodesToRender.length,
453465
totalItemsToNavigate: ({ nodesToRender }) => nodesToRender.length,
454466
lastActivePathItem: ({ activePath }) => last(activePath),
467+
enableQuickNavigation: () => (
468+
getSetting(['features', 'docs', 'quickNavigation', 'enable'], false)
469+
),
455470
},
456471
created() {
457472
this.restorePersistedState();
@@ -1053,6 +1068,9 @@ export default {
10531068
this.focusIndex(parentIndex);
10541069
},
10551070
},
1071+
provide() {
1072+
return { store: this.store };
1073+
},
10561074
};
10571075
</script>
10581076

@@ -1066,6 +1084,13 @@ $filter-height: 71px;
10661084
$navigator-head-background: var(--color-fill-secondary) !default;
10671085
$navigator-head-background-active: var(--color-fill-tertiary) !default;
10681086
1087+
.magnifier-icon {
1088+
height: 20px;
1089+
width: auto;
1090+
margin: auto;
1091+
padding-left: 5px;
1092+
}
1093+
10691094
.navigator-card {
10701095
--card-vertical-spacing: #{$navigator-card-vertical-spacing};
10711096
display: flex;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<!--
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2022 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
-->
10+
11+
<script>
12+
/**
13+
* Component used to mark plain text.
14+
*/
15+
export default {
16+
name: 'QuickNavigationHighlighter',
17+
props: {
18+
text: {
19+
type: String,
20+
required: true,
21+
},
22+
matcherText: {
23+
type: String,
24+
default: '',
25+
},
26+
},
27+
render(createElement) {
28+
const { matcherText, text } = this;
29+
const children = [];
30+
let lastIndex = 0;
31+
[...matcherText].forEach((char) => {
32+
const charIndex = text.indexOf(char, lastIndex);
33+
if (lastIndex) {
34+
children.push(createElement('span', text.slice(lastIndex, charIndex)));
35+
}
36+
const nextIndex = charIndex + 1;
37+
children.push(createElement('span', { class: 'match' }, text.slice(charIndex, nextIndex)));
38+
lastIndex = nextIndex;
39+
});
40+
return createElement('p', { class: 'highlight' }, children);
41+
},
42+
};
43+
</script>
44+
<style lang="scss" scoped>
45+
@import 'docc-render/styles/_core.scss';
46+
47+
.highlight {
48+
display: inline;
49+
50+
/deep/ .match {
51+
font-weight: $font-weight-semibold;
52+
background: var(--color-fill-light-blue-secondary);
53+
}
54+
}
55+
</style>
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<!--
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2022 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
-->
10+
11+
<template>
12+
<div class="quick-navigation-modal">
13+
<div class="container">
14+
<div
15+
class="close-icon"
16+
@click="closeQuickNavigationModal()"
17+
>
18+
<p>
19+
Close
20+
</p>
21+
</div>
22+
<input
23+
ref="input"
24+
class="filter"
25+
v-model="userInput"
26+
type="text"
27+
/>
28+
<div>
29+
<div
30+
v-for="(symbol, idx) in filteredSymbols"
31+
class="symbol-match"
32+
@click="closeQuickNavigationModal()"
33+
:key="idx"
34+
>
35+
<Reference :url="symbol.path" :id="idx">
36+
<span>
37+
-
38+
{{ symbol.title.slice(0, symbol.start) }}
39+
</span>
40+
<QuickNavigationHighlighter
41+
:text="symbol.substring"
42+
:matcherText="debouncedInput"
43+
/>
44+
<span>
45+
{{ symbol.title.slice(symbol.start + symbol.matchLength) }}
46+
</span>
47+
</Reference>
48+
</div>
49+
</div>
50+
</div>
51+
</div>
52+
</template>
53+
54+
<script>
55+
import debounce from 'docc-render/utils/debounce';
56+
import QuickNavigationHighlighter from 'docc-render/components/Navigator/QuickNavigationHighlighter.vue';
57+
import Reference from 'docc-render/components/ContentNode/Reference.vue';
58+
59+
export default {
60+
name: 'QuickNavigationModal',
61+
components: {
62+
QuickNavigationHighlighter,
63+
Reference,
64+
},
65+
inject: ['quickNavigationStore'],
66+
data() {
67+
return {
68+
debouncedInput: '',
69+
quickNavigationStore: this.quickNavigationStore,
70+
userInput: '',
71+
};
72+
},
73+
computed: {
74+
filteredSymbols: ({
75+
flattenIndex,
76+
constructFuzzyRegex,
77+
fuzzyMatch,
78+
debouncedInput,
79+
orderSymbolsByPriority,
80+
}) => {
81+
const symbols = flattenIndex.filter(symbol => (
82+
symbol.type !== 'groupMarker'
83+
&& symbol.title != null
84+
));
85+
if (!debouncedInput) return [];
86+
const matches = fuzzyMatch({
87+
debouncedInput: debouncedInput.toLowerCase(),
88+
symbols,
89+
processedInputRegex: new RegExp(constructFuzzyRegex(debouncedInput)),
90+
});
91+
// Return the first 20 symbols out of sorted ones
92+
return orderSymbolsByPriority(matches).slice(0, 20);
93+
},
94+
flattenIndex: ({ quickNavigationStore }) => quickNavigationStore.state.flattenIndex,
95+
},
96+
methods: {
97+
closeQuickNavigationModal() {
98+
this.quickNavigationStore.toggleShowQuickNavigationModal();
99+
},
100+
constructFuzzyRegex(userInput) {
101+
// Construct regex for fuzzy match
102+
// Ex:
103+
// foobar -> f[^f]*?o[^o]*?o[^o]*?b[^b]*?a[^a]*?r
104+
return [...userInput].reduce((prev, char, index) => (
105+
prev
106+
.concat(char.toLowerCase())
107+
.concat(index < userInput.length - 1 ? `[^${char.toLowerCase()}]*?` : '')
108+
), '');
109+
},
110+
debounceInput: debounce(function debounceInput(value) {
111+
this.debouncedInput = value;
112+
}, 500),
113+
fuzzyMatch: ({ debouncedInput, symbols, processedInputRegex }) => (
114+
symbols.map((symbol) => {
115+
const match = processedInputRegex.exec(symbol.title.toLowerCase());
116+
// Dismiss if symbol isn't matched
117+
if (!match) return false;
118+
119+
const matchLength = match[0].length;
120+
const inputLength = debouncedInput.length;
121+
// Dismiss if match length is greater than 3x the input's length
122+
if (matchLength > inputLength * 3) return false;
123+
124+
return ({
125+
title: symbol.title,
126+
path: symbol.path,
127+
inputLengthDifference: symbol.title.length - inputLength,
128+
matchLength,
129+
matchLengthDifference: matchLength - inputLength,
130+
start: match.index,
131+
substring: match[0],
132+
});
133+
}).filter(Boolean)
134+
),
135+
orderSymbolsByPriority(matchingSymbols) {
136+
return matchingSymbols.sort((a, b) => {
137+
// Shortests symbol match title have preference over larger titles
138+
if (a.matchLengthDifference > b.matchLengthDifference) return 1;
139+
if (a.matchLengthDifference < b.matchLengthDifference) return -1;
140+
// Shortests symbol title have preference over larger titles
141+
if (a.inputLengthDifference > b.inputLengthDifference) return 1;
142+
if (a.inputLengthDifference < b.inputLengthDifference) return -1;
143+
// Matches at the beginning of string have relevance over matches at the end
144+
if (a.start > b.start) return 1;
145+
if (a.start < b.start) return -1;
146+
return 0;
147+
});
148+
},
149+
},
150+
watch: {
151+
userInput: 'debounceInput',
152+
},
153+
};
154+
</script>
155+
156+
<style scoped lang="scss">
157+
@import 'docc-render/styles/_core.scss';
158+
159+
.close-icon {
160+
padding-bottom: 10px;
161+
}
162+
.container {
163+
flex-direction: row;
164+
}
165+
.filter {
166+
border-radius: $border-radius;
167+
background-color: var(--color-fill);
168+
border: solid var(--color-figure-gray) 1px;
169+
padding: 20px;
170+
width: 80%;
171+
margin-bottom: 20px;
172+
}
173+
.quick-navigation-modal {
174+
background-color: var(--color-fill);
175+
position: fixed;
176+
top: 0;
177+
bottom: 0;
178+
left: 0;
179+
right: 0;
180+
z-index: 100;
181+
padding: 100px;
182+
overflow: scroll;
183+
}
184+
.symbol-match {
185+
width: fit-content;
186+
}
187+
188+
</style>

0 commit comments

Comments
 (0)