Skip to content

Commit 8a1e6a0

Browse files
committed
refactor observer logic, more efficient
1 parent aba7be7 commit 8a1e6a0

File tree

5 files changed

+78
-71
lines changed

5 files changed

+78
-71
lines changed

demo/components/Sidebar/TOC.vue

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts" setup>
2-
import { computed, ComputedRef, inject, watch } from 'vue';
2+
import { computed, ComputedRef, inject } from 'vue';
33
import { useActive } from '../../../src/useActive';
44
import animateScrollTo from 'animated-scroll-to';
55
@@ -19,12 +19,10 @@ const { clickType } = inject('DemoRadios') as {
1919
const { activeIndex, activeId, setActive, isActive } = useActive(targets, {
2020
rootId,
2121
overlayHeight,
22-
minWidth: 0,
23-
/* jumpToFirst: false,
24-
jumpToLast: false, */
25-
boundaryOffset: {
26-
toTop: 100,
27-
},
22+
/* boundaryOffset: {
23+
toBottom: 100,
24+
toTop: -100,
25+
}, */
2826
});
2927
3028
const activeItemHeight = computed(
@@ -100,6 +98,7 @@ a {
10098
color: rgba(255, 255, 255, 0.646);
10199
padding: 2.5px 0;
102100
width: 100%;
101+
--webkit-tap-highlight-color: rgba(0, 0, 0, 0);
103102
}
104103
105104
a:hover {

package.json

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,18 @@
1414
"dev": "vite",
1515
"preview": "vite preview"
1616
},
17-
"dependencies": {},
1817
"devDependencies": {
19-
"@rollup/plugin-terser": "^0.2.1",
18+
"@rollup/plugin-terser": "^0.3.0",
2019
"@types/node": "^18.11.18",
2120
"@vitejs/plugin-vue": "^4.0.0",
2221
"animated-scroll-to": "^2.3.0",
23-
"prettier": "^2.8.1",
24-
"typescript": "^4.9.3",
25-
"vite": "^4.0.0",
22+
"prettier": "^2.8.2",
23+
"typescript": "^4.9.4",
24+
"vite": "^4.0.4",
2625
"vite-plugin-dts": "^1.7.1",
2726
"vue": "^3.2.45",
2827
"vue-router": "^4.1.6",
29-
"vue-tsc": "^1.0.11"
28+
"vue-tsc": "^1.0.24"
3029
},
3130
"peerDependencies": {
3231
"vue": ">=3.0.0"

src/useActive.ts

Lines changed: 58 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import {
22
ref,
3-
Ref,
43
onMounted,
54
computed,
65
unref,
76
watch,
87
isRef,
98
isReactive,
109
onBeforeUnmount,
10+
reactive,
11+
type Ref,
1112
} from 'vue';
1213
import { useScroll } from './useScroll';
13-
import { getEdges, getRects, useRestrictedRef, isSSR, FIXED_TO_TOP_OFFSET } from './utils';
14+
import { getEdges, useMediaRef, isSSR, FIXED_OFFSET } from './utils';
1415

1516
type UseActiveTitleOptions = {
1617
jumpToFirst?: boolean;
@@ -67,19 +68,26 @@ export function useActive(
6768
const media = `(min-width: ${minWidth}px)`;
6869

6970
// Internal
70-
const root = ref<HTMLElement | null>(null);
71-
const rootTop = ref(0);
72-
const targets = ref<HTMLElement[]>([]);
7371
const matchMedia = ref(isSSR || window.matchMedia(media).matches);
72+
const root = ref<HTMLElement | null>(null);
73+
const targets = reactive({
74+
elements: [] as HTMLElement[],
75+
top: new Map<string, number>(),
76+
bottom: new Map<string, number>(),
77+
});
7478

7579
const isHTML = computed(() => typeof rootId !== 'string');
76-
const ids = computed(() => targets.value.map(({ id }) => id));
80+
const ids = computed(() => targets.elements.map(({ id }) => id));
7781

7882
// Returned values
79-
const activeId = useRestrictedRef(matchMedia, '');
83+
const activeId = useMediaRef(matchMedia, '');
8084
const activeIndex = computed(() => ids.value.indexOf(activeId.value));
8185

82-
// Runs onMount and whenever the user array changes
86+
function getTop() {
87+
return root.value!.getBoundingClientRect().top - (isHTML.value ? 0 : root.value!.scrollTop);
88+
}
89+
90+
// Runs onMount, onResize and whenever the user array changes
8391
function setTargets() {
8492
let _targets = <HTMLElement[]>[];
8593

@@ -91,7 +99,14 @@ export function useActive(
9199
});
92100

93101
_targets.sort((a, b) => a.offsetTop - b.offsetTop);
94-
targets.value = _targets;
102+
targets.elements = _targets;
103+
104+
const rootTop = getTop();
105+
targets.elements.forEach((target) => {
106+
const { top, bottom } = target.getBoundingClientRect();
107+
targets.top.set(target.id, top - rootTop);
108+
targets.bottom.set(target.id, bottom - rootTop);
109+
});
95110
}
96111

97112
function jumpToEdges() {
@@ -123,31 +138,50 @@ export function useActive(
123138
}
124139
}
125140

141+
function getSentinel() {
142+
return isHTML.value ? getTop() : -root.value!.scrollTop;
143+
}
144+
126145
// Sets first target that LEFT the top
127146
function onScrollDown({ isCancel } = { isCancel: false }) {
128-
// overlayHeight not needed as 'scroll-margin-top' is set instead
129-
const offset = rootTop.value + toBottom!;
130-
const outTargets = Array.from(getRects(targets.value, 'OUT', offset).keys());
131-
const firstOut = outTargets[outTargets.length - 1] ?? (jumpToFirst ? ids.value[0] : '');
147+
let firstOut = jumpToFirst ? ids.value[0] : '';
148+
149+
const sentinel = getSentinel();
150+
const offset = FIXED_OFFSET + overlayHeight + toBottom!;
151+
152+
Array.from(targets.top).some(([id, top]) => {
153+
if (sentinel + top < offset) {
154+
return (firstOut = id), false;
155+
}
156+
return true; // Return last
157+
});
132158

133-
// Prevent innatural highlighting with smoothscroll/custom easings
159+
// Prevent innatural highlighting with smoothscroll/custom easings...
134160
if (ids.value.indexOf(firstOut) > ids.value.indexOf(activeId.value)) {
135161
return (activeId.value = firstOut);
136162
}
137163

164+
// ...but not on scroll cancel
138165
if (isCancel) {
139166
activeId.value = firstOut;
140167
}
141168
}
142169

143170
// Sets first target that ENTERED the top
144171
function onScrollUp() {
145-
const offset = overlayHeight + rootTop.value + toTop!;
146-
const firstIn = getRects(targets.value, 'IN', offset).keys().next().value ?? '';
172+
let firstIn = '';
173+
174+
const sentinel = getSentinel();
175+
const offset = FIXED_OFFSET + overlayHeight + toTop!;
176+
177+
Array.from(targets.bottom).some(([id, bottom]) => {
178+
if (sentinel + bottom > offset) {
179+
return (firstIn = id), true; // Return first
180+
}
181+
});
147182

148183
if (!jumpToFirst && firstIn === ids.value[0]) {
149-
const firstTargetTop = getRects(targets.value, 'ALL').values().next().value;
150-
if (firstTargetTop > FIXED_TO_TOP_OFFSET + offset) {
184+
if (sentinel + targets.top.values().next().value > offset) {
151185
return (activeId.value = '');
152186
}
153187
}
@@ -158,29 +192,24 @@ export function useActive(
158192
}
159193

160194
function onResize() {
161-
rootTop.value = isHTML.value ? 0 : root.value!.getBoundingClientRect().top;
162195
matchMedia.value = window.matchMedia(media).matches;
196+
setTargets();
163197
}
164198

165199
onMounted(async () => {
166-
if (isHTML.value) {
167-
root.value = document.documentElement;
168-
} else {
169-
const cRoot = document.getElementById(rootId as string);
170-
if (cRoot) {
171-
root.value = cRoot;
172-
rootTop.value = cRoot.getBoundingClientRect().top;
173-
}
174-
}
200+
root.value = isHTML.value
201+
? document.documentElement
202+
: document.getElementById(rootId as string);
175203

176204
// https://github.com/nuxt/content/issues/1799
177205
await new Promise((resolve) => setTimeout(resolve));
206+
178207
setTargets();
179208

180209
window.addEventListener('resize', onResize, { passive: true });
181210

182211
if (matchMedia.value) {
183-
const hashId = targets.value.find(({ id }) => id === location.hash.slice(1))?.id;
212+
const hashId = targets.elements.find(({ id }) => id === location.hash.slice(1))?.id;
184213
if (hashId) {
185214
return (activeId.value = hashId);
186215
}

src/useScroll.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { watch, onMounted, ref, Ref, ComputedRef, computed } from 'vue';
2-
import { isSSR, useRestrictedRef } from './utils';
2+
import { isSSR, useMediaRef } from './utils';
33

44
type UseListenersOptions = {
55
isHTML: ComputedRef<boolean>;
@@ -11,7 +11,7 @@ type UseListenersOptions = {
1111
const ONCE = { once: true };
1212

1313
export function useScroll({ isHTML, root, _setActive, matchMedia }: UseListenersOptions) {
14-
const isClick = useRestrictedRef(matchMedia, false);
14+
const isClick = useMediaRef(matchMedia, false);
1515
const isReady = ref(false);
1616
const clickY = computed(() => (isClick.value ? getNextY() : 0));
1717

@@ -34,7 +34,7 @@ export function useScroll({ isHTML, root, _setActive, matchMedia }: UseListeners
3434
// console.log('Scrolling...');
3535
return requestAnimationFrame(scrollEnd);
3636
}
37-
// When equal, wait for 20 frames after scroll and 10 after mount to be sure is idle
37+
// When equal, wait for n frames after scroll to make sure is idle
3838
frameCount++;
3939
if (frameCount === maxFrames) {
4040
isReady.value = true;
@@ -83,7 +83,7 @@ export function useScroll({ isHTML, root, _setActive, matchMedia }: UseListeners
8383
}
8484

8585
onMounted(() => {
86-
if (matchMedia.value && location.hash) {
86+
if (matchMedia.value && window.location.hash) {
8787
// Wait for any eventual scroll to hash triggered by browser to end
8888
setReady(10);
8989
} else {
@@ -132,6 +132,7 @@ export function useScroll({ isHTML, root, _setActive, matchMedia }: UseListeners
132132
rootEl.addEventListener('scroll', resetReady, ONCE);
133133
rootEl.addEventListener('wheel', reScroll, ONCE);
134134
rootEl.addEventListener('keydown', onSpaceBar as EventListener, ONCE);
135+
rootEl.addEventListener('touchmove', reScroll as EventListener, ONCE);
135136
rootEl.addEventListener('pointerdown', onPointerDown as EventListener, ONCE);
136137
}
137138

@@ -141,6 +142,7 @@ export function useScroll({ isHTML, root, _setActive, matchMedia }: UseListeners
141142
rootEl.removeEventListener('scroll', resetReady);
142143
rootEl.removeEventListener('wheel', reScroll);
143144
rootEl.removeEventListener('keydown', onSpaceBar as EventListener);
145+
rootEl.removeEventListener('touchmove', reScroll as EventListener);
144146
rootEl.removeEventListener('pointerdown', onPointerDown as EventListener);
145147
}
146148
});

src/utils.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { customRef, Ref } from 'vue';
33
export const isSSR = typeof window === 'undefined';
44

55
export const FIXED_TO_TOP_OFFSET = 10;
6-
export const FIXED_BOUNDARY_OFFSET = 5;
6+
export const FIXED_OFFSET = 5;
77

8-
export function useRestrictedRef<T>(matchMedia: Ref<boolean>, defaultValue: T): Ref<T> {
8+
// When users set refs, if no media match, set default value
9+
export function useMediaRef<T>(matchMedia: Ref<boolean>, defaultValue: T): Ref<T> {
910
const _customRef = customRef<T>((track, trigger) => {
1011
let value = defaultValue;
1112
return {
@@ -23,29 +24,6 @@ export function useRestrictedRef<T>(matchMedia: Ref<boolean>, defaultValue: T):
2324
return _customRef;
2425
}
2526

26-
export function getRects(targets: HTMLElement[], filter: 'IN' | 'OUT' | 'ALL', userOffset = 0) {
27-
const map = new Map<string, number>();
28-
29-
targets.forEach((target) => {
30-
if (filter === 'ALL') {
31-
return map.set(target.id, target.getBoundingClientRect().top);
32-
}
33-
34-
const isOut = filter === 'OUT';
35-
const rectProp = target.getBoundingClientRect()[isOut ? 'top' : 'bottom'];
36-
const scrollMargin = isOut ? parseFloat(getComputedStyle(target).scrollMarginTop) : 0;
37-
38-
const offset = FIXED_BOUNDARY_OFFSET + userOffset + scrollMargin;
39-
const condition = isOut ? rectProp <= offset : rectProp >= offset;
40-
41-
if (condition) {
42-
map.set(target.id, rectProp);
43-
}
44-
});
45-
46-
return map;
47-
}
48-
4927
export function getEdges(root: HTMLElement) {
5028
// Mobile devices
5129
const clientHeight = root === document.documentElement ? window.innerHeight : root.clientHeight;

0 commit comments

Comments
 (0)