diff --git a/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js b/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js index 8cac7cc51..46a002693 100644 --- a/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js @@ -279,4 +279,352 @@ describe('useActiveExcursion', () => { expect(activeExcursion).toMatchObject({title: 'excursion'}); }); + describe('scrolling', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('calls scrollToTarget when navigating from main to excursion section', async () => { + const scrollToTarget = jest.fn(); + + const {result} = renderHookInEntry( + () => useActiveExcursion(useEntryStructure(), {scrollToTarget}), + { + seed: { + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 10, storylineId: 1, configuration: {title: 'intro'}}, + {id: 11, storylineId: 2, configuration: {title: 'excursion'}}, + ], + sections: [ + {id: 100, chapterId: 10, permaId: 500}, + {id: 101, chapterId: 11, permaId: 501}, + {id: 102, chapterId: 11, permaId: 502}, + ] + } + } + ); + + act(() => changeLocationHash('#section-502')); + act(() => jest.advanceTimersByTime(500)); + + const {activeExcursion} = result.current; + expect(activeExcursion).toMatchObject({title: 'excursion'}); + expect(scrollToTarget).toHaveBeenCalledWith({id: 102}); + }); + + it('calls scrollToTarget when navigating from excursion to main section', async () => { + window.location.hash = '#excursion'; + const scrollToTarget = jest.fn(); + + const {result} = renderHookInEntry( + () => useActiveExcursion(useEntryStructure(), {scrollToTarget}), + { + seed: { + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 10, storylineId: 1, configuration: {title: 'intro'}}, + {id: 11, storylineId: 2, configuration: {title: 'excursion'}}, + ], + sections: [ + {id: 100, chapterId: 10, permaId: 500}, + {id: 101, chapterId: 11, permaId: 501}, + ] + } + } + ); + + act(() => changeLocationHash('#section-500')); + act(() => jest.advanceTimersByTime(500)); + + const {activeExcursion} = result.current; + expect(activeExcursion).toBeUndefined(); + expect(scrollToTarget).toHaveBeenCalledWith({id: 100}); + }); + + it('calls scrollToTarget when navigating from excursion A to excursion B', async () => { + window.location.hash = '#excursion1'; + const scrollToTarget = jest.fn(); + + const {result} = renderHookInEntry( + () => useActiveExcursion(useEntryStructure(), {scrollToTarget}), + { + seed: { + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 10, storylineId: 1, configuration: {title: 'intro'}}, + {id: 11, storylineId: 2, configuration: {title: 'excursion1'}}, + {id: 12, storylineId: 2, configuration: {title: 'excursion2'}} + ], + sections: [ + {id: 100, chapterId: 10, permaId: 500}, + {id: 101, chapterId: 11, permaId: 501}, + {id: 102, chapterId: 12, permaId: 502}, + {id: 103, chapterId: 12, permaId: 503} + ] + } + } + ); + + act(() => changeLocationHash('#section-503')); + act(() => jest.advanceTimersByTime(500)); + + const {activeExcursion} = result.current; + expect(activeExcursion).toMatchObject({title: 'excursion2'}); + expect(scrollToTarget).toHaveBeenCalledWith({id: 103}); + }); + + it('delays scroll by 500ms', async () => { + const scrollToTarget = jest.fn(); + + renderHookInEntry( + () => useActiveExcursion(useEntryStructure(), {scrollToTarget}), + { + seed: { + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 10, storylineId: 1, configuration: {title: 'intro'}}, + {id: 11, storylineId: 2, configuration: {title: 'excursion'}}, + ], + sections: [ + {id: 100, chapterId: 10, permaId: 500}, + {id: 101, chapterId: 11, permaId: 501}, + {id: 102, chapterId: 11, permaId: 502}, + ] + } + } + ); + + act(() => { + changeLocationHash('#section-502'); + }); + + expect(scrollToTarget).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(499); + }); + + expect(scrollToTarget).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1); + }); + + expect(scrollToTarget).toHaveBeenCalledWith({id: 102}); + }); + + it('does not call scrollToTarget when navigating within main storyline', async () => { + const scrollToTarget = jest.fn(); + + const {result} = renderHookInEntry( + () => useActiveExcursion(useEntryStructure(), {scrollToTarget}), + { + seed: { + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 10, storylineId: 1, configuration: {title: 'intro'}}, + {id: 11, storylineId: 2, configuration: {title: 'excursion'}}, + ], + sections: [ + {id: 100, chapterId: 10, permaId: 500}, + {id: 103, chapterId: 10, permaId: 503}, + {id: 101, chapterId: 11, permaId: 501}, + ] + } + } + ); + + act(() => changeLocationHash('#section-503')); + act(() => jest.advanceTimersByTime(500)); + + const {activeExcursion} = result.current; + expect(activeExcursion).toBeUndefined(); + expect(scrollToTarget).not.toHaveBeenCalled(); + }); + + it('does not call scrollToTarget when navigating within same excursion', async () => { + window.location.hash = '#excursion'; + const scrollToTarget = jest.fn(); + + const {result} = renderHookInEntry( + () => useActiveExcursion(useEntryStructure(), {scrollToTarget}), + { + seed: { + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 10, storylineId: 1, configuration: {title: 'intro'}}, + {id: 11, storylineId: 2, configuration: {title: 'excursion'}}, + ], + sections: [ + {id: 100, chapterId: 10, permaId: 500}, + {id: 101, chapterId: 11, permaId: 501}, + {id: 102, chapterId: 11, permaId: 502}, + ] + } + } + ); + + act(() => changeLocationHash('#section-502')); + act(() => jest.advanceTimersByTime(500)); + + const {activeExcursion} = result.current; + expect(activeExcursion).toMatchObject({title: 'excursion'}); + expect(scrollToTarget).not.toHaveBeenCalled(); + }); + + it('does not call scrollToTarget when hash does not match anything', async () => { + const scrollToTarget = jest.fn(); + + const {result} = renderHookInEntry( + () => useActiveExcursion(useEntryStructure(), {scrollToTarget}), + { + seed: { + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 10, storylineId: 1, configuration: {title: 'intro'}}, + {id: 11, storylineId: 2, configuration: {title: 'excursion'}}, + ], + sections: [ + {id: 100, chapterId: 10, permaId: 500}, + {id: 101, chapterId: 11, permaId: 501}, + ] + } + } + ); + + act(() => changeLocationHash('#nonexistent')); + act(() => jest.advanceTimersByTime(500)); + + const {activeExcursion} = result.current; + expect(activeExcursion).toBeUndefined(); + expect(scrollToTarget).not.toHaveBeenCalled(); + }); + + it('scrolls again after returning from excursion and re-entering', async () => { + const scrollToTarget = jest.fn(); + + const {result} = renderHookInEntry( + () => useActiveExcursion(useEntryStructure(), {scrollToTarget}), + { + seed: { + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 10, storylineId: 1, configuration: {title: 'intro'}}, + {id: 11, storylineId: 2, configuration: {title: 'excursion'}}, + ], + sections: [ + {id: 100, chapterId: 10, permaId: 500}, + {id: 101, chapterId: 11, permaId: 501}, + {id: 102, chapterId: 11, permaId: 502}, + ] + } + } + ); + + // Enter excursion (non-first section) + act(() => changeLocationHash('#section-502')); + act(() => jest.advanceTimersByTime(500)); + + expect(scrollToTarget).toHaveBeenCalledWith({id: 102}); + scrollToTarget.mockClear(); + + // Return from excursion + act(() => result.current.returnFromExcursion()); + + expect(result.current.activeExcursion).toBeUndefined(); + + // Re-enter excursion - should scroll again + act(() => changeLocationHash('#section-502')); + act(() => jest.advanceTimersByTime(500)); + + expect(scrollToTarget).toHaveBeenCalledWith({id: 102}); + }); + + it('does not scroll when navigating to excursion via slug', async () => { + const scrollToTarget = jest.fn(); + + const {result} = renderHookInEntry( + () => useActiveExcursion(useEntryStructure(), {scrollToTarget}), + { + seed: { + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 10, storylineId: 1, configuration: {title: 'intro'}}, + {id: 11, storylineId: 2, configuration: {title: 'excursion'}}, + ], + sections: [ + {id: 100, chapterId: 10, permaId: 500}, + {id: 101, chapterId: 11, permaId: 501}, + ] + } + } + ); + + act(() => changeLocationHash('#excursion')); + act(() => jest.advanceTimersByTime(500)); + + const {activeExcursion} = result.current; + expect(activeExcursion).toMatchObject({title: 'excursion'}); + expect(scrollToTarget).not.toHaveBeenCalled(); + }); + + it('does not scroll when navigating to first section of excursion', async () => { + const scrollToTarget = jest.fn(); + + const {result} = renderHookInEntry( + () => useActiveExcursion(useEntryStructure(), {scrollToTarget}), + { + seed: { + storylines: [ + {id: 1, configuration: {main: true}}, + {id: 2} + ], + chapters: [ + {id: 10, storylineId: 1, configuration: {title: 'intro'}}, + {id: 11, storylineId: 2, configuration: {title: 'excursion'}}, + ], + sections: [ + {id: 100, chapterId: 10, permaId: 500}, + {id: 101, chapterId: 11, permaId: 501}, + {id: 102, chapterId: 11, permaId: 502}, + ] + } + } + ); + + act(() => changeLocationHash('#section-501')); + act(() => jest.advanceTimersByTime(500)); + + const {activeExcursion} = result.current; + expect(activeExcursion).toMatchObject({title: 'excursion'}); + expect(scrollToTarget).not.toHaveBeenCalled(); + }); + }); }); diff --git a/entry_types/scrolled/package/src/frontend/Content.js b/entry_types/scrolled/package/src/frontend/Content.js index 62f2b2440..bf909e307 100644 --- a/entry_types/scrolled/package/src/frontend/Content.js +++ b/entry_types/scrolled/package/src/frontend/Content.js @@ -24,12 +24,13 @@ import styles from './Content.module.css'; export const Content = withInlineEditingDecorator('ContentDecorator', function Content(props) { const entryStructure = useEntryStructure(); + const scrollToTarget = useScrollToTarget(); const { activeExcursion, activateExcursionOfSection, returnFromExcursion - } = useActiveExcursion(entryStructure); + } = useActiveExcursion(entryStructure, {scrollToTarget}); const [currentSectionIndex, setCurrentSectionIndexState] = useCurrentSectionIndexState(); const [currentExcursionSectionIndex, setCurrentExcursionSectionIndex] = useState(0); @@ -52,8 +53,6 @@ export const Content = withInlineEditingDecorator('ContentDecorator', function C triggerSectionChange(section, activeExcursion.sections.length); }, [updateExcursionChapterSlug, triggerSectionChange, activeExcursion]); - const scrollToTarget = useScrollToTarget(); - const receiveMessage = useCallback(data => { if (data.type === 'SCROLL_TO_SECTION') { activateExcursionOfSection({id: data.payload.id}); diff --git a/entry_types/scrolled/package/src/frontend/useActiveExcursion.js b/entry_types/scrolled/package/src/frontend/useActiveExcursion.js index 865ba9bb0..c65a2f1ce 100644 --- a/entry_types/scrolled/package/src/frontend/useActiveExcursion.js +++ b/entry_types/scrolled/package/src/frontend/useActiveExcursion.js @@ -1,35 +1,68 @@ import {useCallback, useEffect, useState, useMemo, useRef} from 'react'; -export function useActiveExcursion(entryStructure) { +export function useActiveExcursion(entryStructure, {scrollToTarget} = {}) { const [activeExcursionId, setActiveExcursionId] = useState(); + const [scrollTarget, setScrollTarget] = useState(); const returnUrlRef = useRef(null); useEffect(() => { function handleHashChange(event) { const hash = window.__ACTIVE_EXCURSION__ || // Used in Storybook window.location.hash.slice(1); - const excursion = findExcursionByHash(hash); + const {excursion, sectionId} = findScrollTargetByHash(hash); if (excursion && event?.oldURL && !returnUrlRef.current) { returnUrlRef.current = event.oldURL; } - setActiveExcursionId(excursion?.id); + setActiveExcursionId(prevExcursionId => { + const excursionChanged = excursion?.id !== prevExcursionId; + + if (excursionChanged && sectionId) { + setScrollTarget(sectionId); + } + + return excursion?.id; + }); } - function findExcursionByHash(hash) { + function findScrollTargetByHash(hash) { if (hash.startsWith('section-')) { const permaId = parseInt(hash.replace('section-', ''), 10); - return entryStructure.excursions.find( - chapter => chapter.sections.find( - section => section.permaId === permaId - ) - ); + + for (const chapter of entryStructure.excursions) { + const section = chapter.sections.find(s => s.permaId === permaId); + if (section) { + const isFirstSection = section.id === chapter.sections[0]?.id; + return {excursion: chapter, sectionId: isFirstSection ? null : section.id}; + } + } + + for (const chapter of entryStructure.main) { + const section = chapter.sections.find(s => s.permaId === permaId); + if (section) { + return {excursion: null, sectionId: section.id}; + } + } + + return {excursion: null, sectionId: null}; + } + + const excursion = entryStructure.excursions.find( + chapter => chapter.chapterSlug === hash + ); + if (excursion) { + return {excursion, sectionId: null}; } - return entryStructure.excursions.find( + const mainChapter = entryStructure.main.find( chapter => chapter.chapterSlug === hash ); + if (mainChapter) { + return {excursion: null, sectionId: mainChapter.sections[0]?.id}; + } + + return {excursion: null, sectionId: null}; } handleHashChange(); @@ -38,6 +71,18 @@ export function useActiveExcursion(entryStructure) { return () => window.removeEventListener('hashchange', handleHashChange); }, [entryStructure]); + useEffect(() => { + if (!scrollTarget) { + return; + } + + setTimeout(() => { + scrollToTarget({id: scrollTarget}); + }, 500); + + setScrollTarget(null); + }, [scrollTarget, scrollToTarget]); + const activateExcursionOfSection = useCallback(({id}) => { const excursion = entryStructure.excursions.find( chapter => chapter.sections.find( diff --git a/entry_types/scrolled/package/src/frontend/useScrollTarget.js b/entry_types/scrolled/package/src/frontend/useScrollTarget.js index 1380e4ac1..962213c9e 100644 --- a/entry_types/scrolled/package/src/frontend/useScrollTarget.js +++ b/entry_types/scrolled/package/src/frontend/useScrollTarget.js @@ -4,7 +4,7 @@ import BackboneEvents from 'backbone-events-standalone'; const ScrollTargetEmitterContext = createContext(); export function ScrollTargetEmitterProvider({children}) { - const emitter = useMemo(() => Object.assign({}, BackboneEvents), []); + const emitter = useMemo(() => Object.assign({}, BackboneEvents), []); return ( @@ -17,7 +17,9 @@ export function useScrollToTarget() { const emitter = useContext(ScrollTargetEmitterContext); return useCallback( - ({id, align, ifNeeded, behavior}) => emitter.trigger(id, {align, ifNeeded, behavior}), + ({id, align, ifNeeded, behavior}) => { + emitter.trigger(id, {align, ifNeeded, behavior}); + }, [emitter] ) } @@ -28,7 +30,7 @@ export function useScrollTarget(id) { const emitter = useContext(ScrollTargetEmitterContext); useEffect(() => { - emitter.on(id, ({align, ifNeeded, behavior}) => { + const handler = ({align, ifNeeded, behavior}) => { if (ref.current) { const rect = ref.current.getBoundingClientRect(); @@ -41,9 +43,11 @@ export function useScrollTarget(id) { behavior: behavior || 'smooth' }); } - }); + }; - return () => emitter.off(id) + emitter.on(id, handler); + + return () => emitter.off(id, handler); }, [id, emitter]); return ref;